Public Access
Compare commits
38
Commits
315e22c220
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4701cebece | ||
|
|
c124d0e5d2 | ||
|
|
c362e7047e | ||
|
|
734ff2e07c | ||
|
|
d842d9e865 | ||
|
|
d924c09953 | ||
|
|
d9b816904a | ||
|
|
395b8e6f99 | ||
|
|
365b69feb4 | ||
|
|
3410cbc598 | ||
|
|
19463a1a4d | ||
|
|
5a2552ddf6 | ||
|
|
6e7fb1e066 | ||
|
|
1b7bafd9f0 | ||
|
|
0af76da9c7 | ||
|
|
878ede914b | ||
|
|
a60011ce9f | ||
|
|
e72222a37f | ||
|
|
6b6ac44551 | ||
|
|
2b2f45b9af | ||
|
|
39a4d5ae49 | ||
|
|
1008034e82 | ||
|
|
1cac0c6b81 | ||
|
|
47a03d2e6a | ||
|
|
fd79c63441 | ||
|
|
9f56742ff6 | ||
|
|
89e2fa3df2 | ||
|
|
06f8089f5a | ||
|
|
3336181cdb | ||
|
|
1f94c87a20 | ||
|
|
509280da14 | ||
|
|
cf21f4a654 | ||
|
|
fb51f836a8 | ||
|
|
d082f3befd | ||
|
|
b6be1879f0 | ||
|
|
afcfacb68b | ||
|
|
68919eaab4 | ||
|
|
ea4ae089ff |
No files matched your search
@@ -36,5 +36,9 @@ EXPOSE 8080
|
|||||||
# Copy the compiled jar file from the builder stage
|
# Copy the compiled jar file from the builder stage
|
||||||
COPY --from=builder /app/target/*.jar app.jar
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
|
|
||||||
|
# No root in the container
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
USER app
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -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:
|
|
||||||
@@ -40,6 +40,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-cache</artifactId>
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
<!-- Rest Caching -->
|
<!-- Rest Caching -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
@@ -82,11 +86,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.micrometer</groupId>
|
|
||||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- testing -->
|
<!-- testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,52 +1,77 @@
|
|||||||
# To Be Played – Auth Service
|
# To Be Played – Auth Service
|
||||||
|
|
||||||
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the
|
Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||||
public profile, and it signs the JWTs that the Core service trusts.
|
by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It handles registration, login, user
|
||||||
|
settings and the public profile, and it signs the JWTs that the Core service trusts.
|
||||||
|
|
||||||
To Be Played is split into three repositories:
|
To Be Played is split into four repositories:
|
||||||
|
|
||||||
| Repository | Role |
|
| Repository | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **ToBePlayed-Auth** (this one) | Users, login, settings, signs JWTs |
|
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
|
||||||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||||
| ToBePlayed-Frontend | React single-page app |
|
| ToBePlayed-Frontend | React single-page app |
|
||||||
|
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Registration with an invitation code, login with email and password (BCrypt).
|
- Registration by email confirmation (a single-use link, valid 24 hours, on whose page the owner chooses the
|
||||||
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP,
|
password; unconfirmed requests are deleted after 7 days) and then admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
|
||||||
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
|
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.
|
wrong password, and every failed login gets the same error.
|
||||||
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
||||||
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
||||||
public key as a JWK, so Core finds the right key after a rotation.
|
public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends
|
||||||
|
every access token signed with the old key at once. The frontend renews them with the refresh cookie.
|
||||||
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
|
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
|
||||||
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
||||||
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
||||||
token that comes back revokes all tokens of that login. Logout revokes them too.
|
token that comes back revokes all tokens of that login. Logout revokes them too.
|
||||||
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
||||||
frontend origin. A `Bearer` header still works for API clients and tests.
|
frontend origin. A `Bearer` header still works for API clients and tests.
|
||||||
- User settings: display name (unique, case-insensitive), language, dark mode.
|
- User settings: display name (unique, case-insensitive), language, dark mode, and the chart form per stat group
|
||||||
|
(`stat_charts`, V31): bars, pie, donut, line or list. `StatCharts` holds the allowed forms per group.
|
||||||
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
||||||
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
||||||
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
||||||
foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but
|
foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but
|
||||||
unverified: a verified link from another account takes them away. Unlinking is possible.
|
unverified: a verified link from another account takes them away. Unlinking is possible.
|
||||||
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
|
- Appearance settings synced across devices: accent color, corner style, wide layout, reduce animations, background and
|
||||||
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||||||
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
|
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
|
||||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
||||||
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
||||||
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
|
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
|
||||||
`style`. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_`
|
`style`. The ticket also names the user's `role`, so Core can show the free looks of an admin. The ticket's
|
||||||
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` tile is shown),
|
||||||
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login:
|
`profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
||||||
both services reject every token with a `typ` claim on normal endpoints.
|
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
||||||
|
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
||||||
|
A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in
|
||||||
|
`src/test/resources/contract/auth-core-contract.json`, the same file as in Core
|
||||||
|
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares both).
|
||||||
|
- Errors: every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
|
||||||
|
`fields`, a duplicate email or display name `409 EMAIL_ALREADY_IN_USE` / `DISPLAY_NAME_ALREADY_IN_USE`, anything
|
||||||
|
unexpected `500 INTERNAL_ERROR` without details (`shared/api/GlobalExceptionController`).
|
||||||
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
|
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
|
||||||
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
|
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
|
||||||
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
|
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
|
||||||
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
|
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
|
||||||
refresh. Core checks the same claim.
|
refresh. Core checks the same claim.
|
||||||
|
**Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`).
|
||||||
|
The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in
|
||||||
|
`admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access
|
||||||
|
token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache
|
||||||
|
TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by
|
||||||
|
SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every
|
||||||
|
write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR
|
||||||
|
UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account.
|
||||||
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
|
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
|
||||||
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
|
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
|
||||||
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
|
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
|
||||||
@@ -55,26 +80,61 @@ To Be Played is split into three repositories:
|
|||||||
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
|
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
|
||||||
service method calling another service's method that needs more.
|
service method calling another service's method that needs more.
|
||||||
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
|
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
|
||||||
(`users.enabled`; blocking ends every login, the login then answers `403 ACCOUNT_BLOCKED`, but only after the
|
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
|
||||||
right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account
|
answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as
|
||||||
|
"Delete my account"). No action on the own account
|
||||||
or on another admin. Every action and every "view as" is a row in `admin_audit`.
|
or on another admin. Every action and every "view as" is a row in `admin_audit`.
|
||||||
|
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
|
||||||
|
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
|
||||||
|
and sets `deleted_at`, so the login answers like a deleted account and the nightly cleanup removes it after 30
|
||||||
|
days. A waiting account cannot be blocked or deleted. Pending and denied users are nowhere public
|
||||||
|
(`shared/account/ActiveUser`).
|
||||||
- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
|
- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
|
||||||
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
|
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
|
||||||
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
||||||
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
||||||
|
A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the
|
||||||
|
active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only
|
||||||
|
sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend
|
||||||
|
renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on
|
||||||
|
by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are
|
||||||
|
on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again"
|
||||||
|
for good (`auto_hint_hidden`).
|
||||||
|
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
|
||||||
|
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
|
||||||
|
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
|
||||||
|
entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`.
|
||||||
|
- **Welcome page**: `user_settings.welcome_seen_at` is null until the user sees the welcome page; "Let's go" and "Skip"
|
||||||
|
set it (`PUT /api/users/settings/welcome-seen`). V24 sets it for every existing user, so only users registered after
|
||||||
|
the deploy get the page.
|
||||||
|
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
|
||||||
|
to the admin page), and to the user "confirm your address" (`CONFIRM_EMAIL`, sent first; the admin mail follows the click), then "your account is active" or "your
|
||||||
|
registration was denied", later "your account is blocked" and "your account will be deleted from <date>", and "reset your password" (`PASSWORD_RESET`, the link) and "your password was changed" (`PASSWORD_CHANGED`, after a reset or a change). The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
|
||||||
|
the user's app language. Plain text only. See "Mail flow".
|
||||||
|
- **Broadcast mail**: a PLATFORM_ADMIN writes one mail with subject and text in English and German (plain text),
|
||||||
|
and sends it to all active users or to picked ones, each in their app language. Every active user gets it.
|
||||||
|
"Send test to me" mails the draft to the admin only, in the admin's language. One `mail_broadcast` row keeps the
|
||||||
|
texts and the counts (recipients, sent, failed); `BROADCAST_SEND` goes to `admin_audit`. The sender takes account
|
||||||
|
mails first and stays under `APP_MAIL_DAILY_LIMIT` per 24 h, 10 of them kept for account mails. A recipient who is
|
||||||
|
blocked or deleted before the send counts as failed.
|
||||||
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
||||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no public profile, no Steam
|
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||||||
link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a
|
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
||||||
new Steam link. A nightly job (04:15, Europe/Berlin) removes
|
undo needs a new Steam link. A nightly job (04:15, Europe/Berlin) removes
|
||||||
users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a
|
users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a
|
||||||
**service token** (EdDSA JWT, 60 s, `typ=service`). `app.account-cleanup.dry-run` (default `true`) only logs.
|
**service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Method | Path | Auth |
|
| Method | Path | Auth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/registration/new` | public |
|
| `POST` | `/api/registration/new` | public |
|
||||||
|
| `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public |
|
||||||
|
| `POST` | `/api/registration/resend` (`{email}`; always `204`) | public |
|
||||||
| `POST` | `/api/auth/login` | public, sets both cookies |
|
| `POST` | `/api/auth/login` | public, sets both cookies |
|
||||||
|
| `POST` | `/api/auth/password-reset` (`{email}`; always `204`; `429 TOO_MANY_ATTEMPTS`: 5 per IP and 15 min) | public |
|
||||||
|
| `POST` | `/api/auth/password-reset/confirm` (`{token, password}`; `204`; `410 RESET_INVALID`; `429`) | public |
|
||||||
|
| `POST` | `/api/auth/password` (`{currentPassword, newPassword}`; `204`; `403 INVALID_PASSWORD`; `429`; under `/api/auth` for the refresh cookie, which keeps the current login) | access token |
|
||||||
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
|
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
|
||||||
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
|
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
|
||||||
| `GET` | `/api/users/me` | access token |
|
| `GET` | `/api/users/me` | access token |
|
||||||
@@ -82,34 +142,57 @@ To Be Played is split into three repositories:
|
|||||||
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
|
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
|
||||||
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
|
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
|
||||||
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
|
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
|
||||||
| `GET` | `/api/admin/users?query=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | 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 |
|
||||||
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN |
|
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
|
||||||
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN |
|
||||||
| `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token |
|
| `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||||
|
| `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | PLATFORM_ADMIN |
|
||||||
|
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
|
||||||
|
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||||
|
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||||
|
| `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||||
|
| `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token |
|
||||||
|
| `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||||||
|
| `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||||||
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
|
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
|
||||||
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN |
|
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount` and the `target*` fields, newest first; `automatic`: `[{key, enabled}]`) | PLATFORM_ADMIN |
|
||||||
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN |
|
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN |
|
||||||
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
|
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
|
||||||
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
|
| `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||||
|
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
|
||||||
|
| `PUT` | `/api/news/seen` (`204`) | access token |
|
||||||
|
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
|
||||||
|
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
|
||||||
|
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||||
|
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||||
|
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `409 LAST_ADMIN` (the only active admin), `429`) | access token |
|
||||||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
||||||
| `PUT` | `/api/users/settings/appearance` | access token |
|
| `PUT` | `/api/users/settings/appearance` | access token |
|
||||||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
||||||
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`) | 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/profile-tiles` (`{tiles: [{id, size}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | 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 |
|
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||||
|
| `PUT` | `/api/users/settings/stat-chart` (`{group, chart}`; `chart` null = back to the default; `204`; `400 VALIDATION_FAILED` for an unknown group or form) | access token |
|
||||||
|
| `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token |
|
||||||
|
| `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
|
| `GET` | `/.well-known/jwks.json` (the current public key as a JWK, `Cache-Control: max-age=300`) | public |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
|
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
||||||
- PostgreSQL 17, Flyway migrations
|
- PostgreSQL 17, Flyway migrations
|
||||||
- JJWT with Ed25519 keys
|
- JJWT with Ed25519 keys
|
||||||
- Spring Boot Actuator + Micrometer (Prometheus)
|
- Spring Boot Actuator (`/actuator/health` only)
|
||||||
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
||||||
- CI: Forgejo Actions, multi-stage Docker image
|
- CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -120,8 +203,17 @@ layers:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
|
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
|
||||||
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
|
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
|
||||||
| Persistence | `persistence` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. |
|
| Persistence | `persistence`, `repo` | JPA entities, Spring Data repositories, `EntityMapper`; `repo` holds `JdbcTemplate` queries. Entities never reach the API layer. |
|
||||||
|
|
||||||
|
`user` owns the tables `users` and `user_settings` through the one JPA entity `UserEntity`. `login` and
|
||||||
|
`registration` read and write `users` with `JdbcTemplate` (refresh tokens stay JPA). Every query for "a user who
|
||||||
|
may log in and is shown to others" uses `shared/account/ActiveUser`.
|
||||||
|
A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the
|
||||||
|
display name and the settings. A new setting needs no new repository method.
|
||||||
|
Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
|
||||||
|
`activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds.
|
||||||
|
Every write evicts what it changes. A request that read the database just before a block or delete can put the old answer back, so the short
|
||||||
|
TTL is the real limit: a blocked or deleted user, or a token from before a role change, works for up to 30 s.
|
||||||
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
|
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
|
||||||
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
||||||
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
|
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
|
||||||
@@ -131,11 +223,26 @@ the login module writes "view as" to the audit table through `shared/audit/Admin
|
|||||||
use each other.
|
use each other.
|
||||||
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
|
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
|
||||||
`shared/audit/AdminAuditLog`.
|
`shared/audit/AdminAuditLog`.
|
||||||
|
`news` (what's new entries and their admin API) is built the same way as `hint`; the seen stamp is a column of
|
||||||
|
`user_settings`, read and written by SQL in its own repo.
|
||||||
|
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
|
||||||
|
event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port
|
||||||
|
`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
|
||||||
|
Broadcasts (`BroadcastService`, `mail_broadcast`) queue one row per recipient; the same job sends them after the
|
||||||
|
account mails and under the daily limit.
|
||||||
|
|
||||||
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
||||||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||||||
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
|
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
|
||||||
`auth_service.shared.test`; all integration tests share one PostgreSQL container.
|
`auth_service.shared.test`. Integration and persistence tests share one PostgreSQL container (`TestPostgres`), and
|
||||||
|
the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations
|
||||||
|
starts another one.
|
||||||
|
|
||||||
|
**Mirrored in Core.** These files exist in both services with small differences; a change in one needs the same
|
||||||
|
change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`,
|
||||||
|
`Public` (Core also has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the
|
||||||
|
ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json`
|
||||||
|
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two).
|
||||||
|
|
||||||
### Login flow
|
### Login flow
|
||||||
|
|
||||||
@@ -147,8 +254,8 @@ sequenceDiagram
|
|||||||
B->>A: POST /api/auth/login
|
B->>A: POST /api/auth/login
|
||||||
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
||||||
B->>C: API call with tbp_access
|
B->>C: API call with tbp_access
|
||||||
C->>A: GET /.well-known/jwks.json (new kid only)
|
C->>A: GET /.well-known/jwks.json (unknown kid only)
|
||||||
A-->>C: Public keys (Ed25519)
|
A-->>C: Current public key (Ed25519, one JWK)
|
||||||
C-->>B: 200, or 401 when the access token expired
|
C-->>B: 200, or 401 when the access token expired
|
||||||
B->>A: POST /api/auth/refresh with tbp_refresh
|
B->>A: POST /api/auth/refresh with tbp_refresh
|
||||||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||||||
@@ -160,8 +267,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||||||
can read them.
|
can read them.
|
||||||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them
|
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core
|
||||||
again only for an unknown `kid`.
|
loads it again only for an unknown `kid`.
|
||||||
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
||||||
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
||||||
|
|
||||||
@@ -195,11 +302,13 @@ sequenceDiagram
|
|||||||
participant A as Auth
|
participant A as Auth
|
||||||
participant C as Core
|
participant C as Core
|
||||||
B->>A: DELETE /api/users/me {password}
|
B->>A: DELETE /api/users/me {password}
|
||||||
A->>A: users.deleted_at = now, revoke refresh tokens
|
A->>A: users.deleted_at = now, revoke refresh tokens, mail row (one transaction)
|
||||||
A->>C: POST /internal/accounts/deleted (service token)
|
A->>C: POST /internal/accounts/deleted (service token)
|
||||||
A-->>B: 204, both cookies cleared
|
A-->>B: 204, both cookies cleared
|
||||||
|
Note over A: an old access token gets 401 within 30 s (activeUsers cache)
|
||||||
Note over A,C: nightly 04:15
|
Note over A,C: nightly 04:15
|
||||||
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
||||||
|
A->>C: PUT /internal/accounts/states (every user)
|
||||||
A->>C: POST /internal/accounts/purge (flagged 30+ days)
|
A->>C: POST /internal/accounts/purge (flagged 30+ days)
|
||||||
A->>A: DELETE users (only when Core answered and no dry run)
|
A->>A: DELETE users (only when Core answered and no dry run)
|
||||||
A->>C: POST /internal/accounts/prune (all user ids)
|
A->>C: POST /internal/accounts/prune (all user ids)
|
||||||
@@ -208,6 +317,108 @@ sequenceDiagram
|
|||||||
A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30
|
A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30
|
||||||
days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
||||||
|
|
||||||
|
### Mail flow
|
||||||
|
|
||||||
|
Each mail is a row in `mail_outbox`, written in the transaction of its event, so the event never fails because of
|
||||||
|
the mail server and no mail is lost when Auth restarts. The text is rendered then, in the user's language (`de`,
|
||||||
|
else English); the admin mail is German.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser (Frontend)
|
||||||
|
participant A as Auth
|
||||||
|
participant M as SMTP (Hostinger)
|
||||||
|
B->>A: admin sends a broadcast
|
||||||
|
A->>A: mail_broadcast row + one outbox row per active user
|
||||||
|
B->>A: register / admin decides / admin blocks / delete account
|
||||||
|
A->>A: event + mail_outbox row (PENDING), one transaction
|
||||||
|
loop every minute (MailOutboxService)
|
||||||
|
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
|
||||||
|
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
|
||||||
|
end
|
||||||
|
Note over A: after 10 tries FAILED and a warning in the log, SENT rows are removed after 30 days
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registration flow
|
||||||
|
|
||||||
|
A registration is a request: the account is UNCONFIRMED until the address owner opens the link in the mail (24 hours,
|
||||||
|
single use, only its SHA-256 is stored; a resend replaces it) and chooses the password there, then PENDING until a
|
||||||
|
PLATFORM_ADMIN decides. The registration form has no password: an unconfirmed user has none and cannot log in (login
|
||||||
|
answers the same `401` as for an unknown address), so nobody can set the password of an address that is not theirs.
|
||||||
|
The admin list shows an unconfirmed request as such; it cannot be accepted, the admin can delete it for good. The badge counts PENDING only and the admin mail follows the confirmation. The nightly cleanup deletes it after 7 days (not while a resent link is still valid), also in dry run.
|
||||||
|
Registering again with an unconfirmed address is a resend: a new link, no second row. At most 3 confirmation mails per
|
||||||
|
address and hour (over all IPs, registration and resend together); over that the answer stays the same and no mail is
|
||||||
|
sent. Unconfirmed requests count toward the 20 that wait. While 20 wait, the
|
||||||
|
registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as New user
|
||||||
|
participant A as Auth
|
||||||
|
participant D as Admin
|
||||||
|
U->>A: POST /api/registration/new
|
||||||
|
A->>A: users.status = UNCONFIRMED, one mail row
|
||||||
|
A-->>U: mail "confirm your address" (link)
|
||||||
|
U->>A: POST /api/registration/confirm {token, password}
|
||||||
|
A->>A: users.status = PENDING, password hash stored
|
||||||
|
A-->>D: mail "a new registration waits"
|
||||||
|
U->>A: login: 403 ACCOUNT_PENDING
|
||||||
|
Note over A: nightly: UNCONFIRMED older than 7 days is deleted
|
||||||
|
D->>A: PUT /api/admin/users/{id}/decision {accepted}
|
||||||
|
alt accepted
|
||||||
|
A->>A: status = ACTIVE
|
||||||
|
A-->>U: mail "your account is active"
|
||||||
|
else denied
|
||||||
|
A->>A: status = DENIED, deleted_at = now (nightly cleanup after 30 days)
|
||||||
|
A-->>U: mail "your registration was denied"
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password reset flow
|
||||||
|
|
||||||
|
The link is valid for 1 hour and works once: the reset is one `UPDATE ... WHERE password_reset_hash = ? AND expires > now
|
||||||
|
AND <active>` that also clears the hash. At most 3 reset mails per address and hour (over all IPs) and 5 requests per IP
|
||||||
|
per 15 minutes; failed confirms are limited to 10 per IP. Other logins keep their access token until it expires (up to
|
||||||
|
15 minutes); their refresh tokens end at once.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant A as Auth
|
||||||
|
participant D as Admin
|
||||||
|
U->>A: POST /api/auth/password-reset {email}
|
||||||
|
A->>A: active user: store token hash + expiry, one mail row
|
||||||
|
A-->>U: 204 (same for any address)
|
||||||
|
A-->>U: mail "reset your password" (link with token)
|
||||||
|
U->>A: POST /api/auth/password-reset/confirm {token, password}
|
||||||
|
A->>A: new password, hash cleared, all refresh tokens revoked
|
||||||
|
A-->>U: 204 and mail "your password was changed"
|
||||||
|
Note over D,A: POST /api/admin/users/{id}/password-reset sends the same mail (active users only) and writes the audit log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steam ID and block sync to Core
|
||||||
|
|
||||||
|
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
||||||
|
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
|
||||||
|
(`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
|
||||||
|
cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display
|
||||||
|
name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name:
|
||||||
|
`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket:
|
||||||
|
`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile
|
||||||
|
hides (#27). The call runs on the task executor, so neither the answer nor a database
|
||||||
|
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant A as Auth
|
||||||
|
participant C as Core
|
||||||
|
A->>A: link / unlink / block / unblock / name change (commit)
|
||||||
|
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
|
||||||
|
C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name
|
||||||
|
Note over A,C: nightly 04:15
|
||||||
|
A->>C: PUT /internal/accounts/states (every user)
|
||||||
|
```
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
### Local development without keys (profile `local`)
|
### Local development without keys (profile `local`)
|
||||||
@@ -222,14 +433,14 @@ other, then start them in this order (Core also starts without Auth and finds it
|
|||||||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||||||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||||||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||||||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
|
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
|
||||||
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||||||
|
|
||||||
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
||||||
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
|
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
|
||||||
restart), log in again. Never use `local` on a server: the dev user's password is public. The
|
restart), log in again. Never use `local` on a server: the dev user's password is public. The
|
||||||
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more
|
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until
|
||||||
users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
|
the dev user accepts it on the admin page. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
|
||||||
|
|
||||||
### With your own keys (profile `dev`)
|
### With your own keys (profile `dev`)
|
||||||
|
|
||||||
@@ -240,24 +451,33 @@ Needs Java 25, Docker and your own keys.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
||||||
service listens on `http://localhost:8080`. Health: `/actuator/health`, metrics: `/actuator/prometheus`.
|
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
||||||
|
|
||||||
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the
|
||||||
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
environment. `local` must not be combined with `prod`.
|
||||||
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
|
|
||||||
local port.
|
Only `tbp_access` gets the domain from `APP_AUTH_COOKIE_DOMAIN` (blank = host-only; production sets the parent
|
||||||
|
domain, so Core gets it too). `tbp_refresh` (`Path=/api/auth`) and `tbp_steam_link` are always host-only. The cookies
|
||||||
|
are `Secure` (`app.auth.cookie-secure`, default `true`). Chrome and Firefox accept `Secure` cookies on
|
||||||
|
`http://localhost`; Safari drops them, so the `local` profile sets `cookie-secure: false`. With your own keys on Safari
|
||||||
|
set `APP_AUTH_COOKIE_SECURE=false`. Open the frontend on `localhost`, not `127.0.0.1`.
|
||||||
|
|
||||||
Configuration comes from environment variables:
|
Configuration comes from environment variables:
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
||||||
| `JWT_KID` | Key id written into the token header |
|
| `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank |
|
||||||
| `AUTH_INVITATION_CODE` | Code required for registration |
|
| `APP_AUTH_COOKIE_DOMAIN` | Domain of `tbp_access`, blank = host-only |
|
||||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the nightly cleanup deletes nothing (it still sends the account states to Core); the 7-day purge of unconfirmed registrations runs anyway |
|
||||||
|
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
|
||||||
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
||||||
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||||||
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
||||||
|
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
||||||
|
| `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:
|
Generate a keypair:
|
||||||
|
|
||||||
@@ -267,6 +487,13 @@ openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
|
|||||||
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Request timing log
|
||||||
|
|
||||||
|
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
|
||||||
|
duration, e.g. `GET /api/users/me 200 12 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
|
||||||
|
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
|
||||||
|
that leaves the filter chain is logged as `500`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -277,7 +504,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Account deletion and data export.
|
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package auth_service.configuration;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableAsync
|
|
||||||
public class AsyncConfig {
|
|
||||||
// This class enables Spring's @Async annotation functionality globally
|
|
||||||
}
|
|
||||||
@@ -1,37 +1,36 @@
|
|||||||
package auth_service.configuration;
|
package auth_service.configuration;
|
||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.cache.CacheManager;
|
import org.springframework.cache.CacheManager;
|
||||||
import org.springframework.cache.annotation.EnableCaching;
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.time.Duration;
|
||||||
|
|
||||||
/**
|
|
||||||
* Global configuration to enable Spring Boot caching (backed by Caffeine).
|
|
||||||
*/
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableCaching
|
@EnableCaching
|
||||||
@Profile("!dev")
|
|
||||||
@Slf4j
|
|
||||||
public class CacheConfig {
|
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, 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
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
log.info("########### Productive cache active ###########");
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats");
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
|
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||||
cacheManager.setCaffeine(caffeineCacheBuilder());
|
.maximumSize(500)
|
||||||
|
.expireAfterWrite(DEFAULT_TTL)
|
||||||
|
.recordStats());
|
||||||
|
cacheManager.registerCustomCache("activeUsers", Caffeine.newBuilder()
|
||||||
|
.maximumSize(10_000)
|
||||||
|
.expireAfterWrite(ACTIVE_USERS_TTL)
|
||||||
|
.recordStats()
|
||||||
|
.build());
|
||||||
return cacheManager;
|
return cacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
Caffeine<Object, Object> caffeineCacheBuilder() {
|
|
||||||
return Caffeine.newBuilder()
|
|
||||||
.initialCapacity(100)
|
|
||||||
.maximumSize(500)
|
|
||||||
.expireAfterAccess(10, TimeUnit.MINUTES)
|
|
||||||
.recordStats();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package auth_service.configuration;
|
|
||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.cache.CacheManager;
|
|
||||||
import org.springframework.cache.annotation.EnableCaching;
|
|
||||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global configuration to enable Spring Boot caching (backed by Caffeine).
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
@EnableCaching
|
|
||||||
@Profile("dev")
|
|
||||||
@Slf4j
|
|
||||||
public class DevCacheConfig {
|
|
||||||
@Bean
|
|
||||||
public CacheManager cacheManager() {
|
|
||||||
log.error("########### DEVELOPMENT CACHE ACTIVE ###########");
|
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
|
|
||||||
cacheManager.setCaffeine(caffeineCacheBuilder());
|
|
||||||
return cacheManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
Caffeine<Object, Object> caffeineCacheBuilder() {
|
|
||||||
return Caffeine.newBuilder()
|
|
||||||
.initialCapacity(100)
|
|
||||||
.maximumSize(500)
|
|
||||||
.expireAfterAccess(1, TimeUnit.SECONDS)
|
|
||||||
.recordStats();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ public class ObservabilityConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the TimedAspect bean to enable @Timed annotations.
|
* Registers the TimedAspect bean to enable @Timed annotations.
|
||||||
* * @param registry The Micrometer MeterRegistry
|
* @param registry The Micrometer MeterRegistry
|
||||||
*
|
*
|
||||||
* @return A new TimedAspect instance
|
* @return A new TimedAspect instance
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package auth_service.configuration.security;
|
package auth_service.configuration.security;
|
||||||
|
|
||||||
import auth_service.shared.api.AuthCookies;
|
import auth_service.shared.api.AuthCookies;
|
||||||
|
import auth_service.shared.security.ActiveUserCheck;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import auth_service.shared.security.UserPrincipal;
|
import auth_service.shared.security.UserPrincipal;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
@@ -24,7 +25,8 @@ import java.util.stream.Stream;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
|
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
|
||||||
* verifies. A stale cookie next to a valid header must not block the request.
|
* 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
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -33,6 +35,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
private static final String BEARER = "Bearer ";
|
private static final String BEARER = "Bearer ";
|
||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
private final ActiveUserCheck activeUserCheck;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(
|
protected void doFilterInternal(
|
||||||
@@ -45,6 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
.flatMap(Optional::stream)
|
.flatMap(Optional::stream)
|
||||||
.map(jwtService::parseLoginToken)
|
.map(jwtService::parseLoginToken)
|
||||||
.flatMap(Optional::stream)
|
.flatMap(Optional::stream)
|
||||||
|
.filter(each -> activeUserCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
|
||||||
.findFirst();
|
.findFirst();
|
||||||
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import java.security.PrivateKey;
|
|||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -32,12 +33,13 @@ import java.util.Optional;
|
|||||||
@Service
|
@Service
|
||||||
public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceTokenIssuer, PublicKeyPublisher {
|
public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceTokenIssuer, PublicKeyPublisher {
|
||||||
|
|
||||||
|
static final String ISSUER = "tbp-auth";
|
||||||
|
static final String LOGIN_AUDIENCE = "tbp-login";
|
||||||
|
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
|
||||||
|
static final String SERVICE_AUDIENCE = "tbp-internal";
|
||||||
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
|
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
|
||||||
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
private static final String PROFILE_TICKET_SUBJECT = "public-profile";
|
||||||
private static final String TYPE_CLAIM = "typ";
|
|
||||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
|
||||||
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
|
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
|
||||||
private static final String SERVICE_TOKEN_TYPE = "service";
|
|
||||||
private static final String ED25519 = "Ed25519";
|
private static final String ED25519 = "Ed25519";
|
||||||
private static final String ROLE_CLAIM = "role";
|
private static final String ROLE_CLAIM = "role";
|
||||||
|
|
||||||
@@ -64,14 +66,17 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateToken(String userId, String email, Long steamId, Role role) {
|
public String generateToken(String userId, Long steamId, Role role, Instant notBefore) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long issuedAt = notBefore == null ? now : Math.max(now, notBefore.toEpochMilli());
|
||||||
var builder = Jwts.builder()
|
var builder = Jwts.builder()
|
||||||
.header().keyId(jwtConfig.getKid()).and()
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
.subject(email)
|
.issuer(ISSUER)
|
||||||
.claim("userId", userId)
|
.audience().add(LOGIN_AUDIENCE).and()
|
||||||
|
.subject(userId)
|
||||||
.claim(ROLE_CLAIM, role.name())
|
.claim(ROLE_CLAIM, role.name())
|
||||||
.issuedAt(new Date(System.currentTimeMillis()))
|
.issuedAt(new Date(issuedAt))
|
||||||
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
|
.expiration(new Date(now + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||||
|
|
||||||
if (steamId != null) {
|
if (steamId != null) {
|
||||||
builder.claim("steamId", steamId);
|
builder.claim("steamId", steamId);
|
||||||
@@ -83,12 +88,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) {
|
public String generateProfileTicket(long userId, Role role, List<String> profileStats, List<String> sections) {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.header().keyId(jwtConfig.getKid()).and()
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
.subject(PROFILE_TICKET_TYPE)
|
.issuer(ISSUER)
|
||||||
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
|
.audience().add(PROFILE_TICKET_AUDIENCE).and()
|
||||||
|
.subject(PROFILE_TICKET_SUBJECT)
|
||||||
.claim("profileUserId", String.valueOf(userId))
|
.claim("profileUserId", String.valueOf(userId))
|
||||||
|
.claim(ROLE_CLAIM, role.name())
|
||||||
.claim("profileStats", profileStats)
|
.claim("profileStats", profileStats)
|
||||||
.claim("sections", sections)
|
.claim("sections", sections)
|
||||||
.issuedAt(new Date(System.currentTimeMillis()))
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
@@ -101,8 +108,9 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
public String generateServiceToken() {
|
public String generateServiceToken() {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.header().keyId(jwtConfig.getKid()).and()
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
|
.issuer(ISSUER)
|
||||||
|
.audience().add(SERVICE_AUDIENCE).and()
|
||||||
.subject("tbp-auth")
|
.subject("tbp-auth")
|
||||||
.claim(TYPE_CLAIM, SERVICE_TOKEN_TYPE)
|
|
||||||
.issuedAt(new Date(System.currentTimeMillis()))
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
.expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS))
|
.expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS))
|
||||||
.signWith(privateKey, Jwts.SIG.EdDSA)
|
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||||
@@ -126,17 +134,26 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
public Optional<UserPrincipal> parseLoginToken(String token) {
|
public Optional<UserPrincipal> parseLoginToken(String token) {
|
||||||
try {
|
try {
|
||||||
Claims claims = parser.parseSignedClaims(token).getPayload();
|
Claims claims = parser.parseSignedClaims(token).getPayload();
|
||||||
String userId = claims.get("userId", String.class);
|
if (!isKind(claims, LOGIN_AUDIENCE)) {
|
||||||
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class),
|
String userId = claims.getSubject();
|
||||||
Role.parse(claims.get(ROLE_CLAIM, String.class))));
|
if (userId == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class),
|
||||||
|
Role.parse(claims.get(ROLE_CLAIM, String.class)),
|
||||||
|
claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant()));
|
||||||
} catch (Exception _) {
|
} catch (Exception _) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isKind(Claims claims, String audience) {
|
||||||
|
return claims.getAudience() != null && claims.getAudience().contains(audience)
|
||||||
|
&& ISSUER.equals(claims.getIssuer());
|
||||||
|
}
|
||||||
|
|
||||||
private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
|
private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
|
||||||
try {
|
try {
|
||||||
byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der);
|
byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.security.authorization.AuthorizationManager;
|
|||||||
import org.springframework.security.authorization.AuthorizationResult;
|
import org.springframework.security.authorization.AuthorizationResult;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
|
||||||
|
|
||||||
import java.lang.reflect.AnnotatedElement;
|
import java.lang.reflect.AnnotatedElement;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -23,9 +22,6 @@ class RoleAuthorizationManager implements AuthorizationManager<MethodInvocation>
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
||||||
if (RequestContextHolder.getRequestAttributes() == null) {
|
|
||||||
return GRANTED;
|
|
||||||
}
|
|
||||||
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
|
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
|
||||||
: invocation.getMethod().getDeclaringClass();
|
: invocation.getMethod().getDeclaringClass();
|
||||||
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
|
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ public class SecurityConfig {
|
|||||||
// 401, not Spring's default 403: the frontend renews the access cookie only on a 401.
|
// 401, not Spring's default 403: the frontend renews the access cookie only on a 401.
|
||||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// Under /api/auth only for the refresh cookie; it changes the password of the logged-in user.
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/auth/password").authenticated()
|
||||||
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
||||||
// The active role: an admin who views as USER gets 403 here.
|
// The active role: an admin who views as USER gets 403 here.
|
||||||
.requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN")
|
.requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN")
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
package auth_service.modules.account.api;
|
package auth_service.modules.account.api;
|
||||||
|
|
||||||
|
import auth_service.modules.account.api.dto.request.ChangePasswordRequest;
|
||||||
import auth_service.modules.account.api.dto.request.DeleteAccountRequest;
|
import auth_service.modules.account.api.dto.request.DeleteAccountRequest;
|
||||||
|
import auth_service.modules.account.api.dto.request.ForgotPasswordRequest;
|
||||||
|
import auth_service.modules.account.api.dto.request.ResetPasswordRequest;
|
||||||
import auth_service.modules.account.service.AccountService;
|
import auth_service.modules.account.service.AccountService;
|
||||||
|
import auth_service.modules.account.service.PasswordResetService;
|
||||||
import auth_service.shared.api.AuthCookies;
|
import auth_service.shared.api.AuthCookies;
|
||||||
|
import auth_service.shared.api.ClientIp;
|
||||||
import auth_service.shared.security.UserPrincipal;
|
import auth_service.shared.security.UserPrincipal;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.CookieValue;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -18,9 +26,10 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class AccountController {
|
public class AccountController {
|
||||||
|
|
||||||
private final AccountService accountService;
|
private final AccountService accountService;
|
||||||
|
private final PasswordResetService passwordResetService;
|
||||||
private final AuthCookies authCookies;
|
private final AuthCookies authCookies;
|
||||||
|
|
||||||
@DeleteMapping(value = "/api/users/me", version = "1.0")
|
@DeleteMapping(value = "/api/users/me")
|
||||||
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody DeleteAccountRequest request) {
|
@Valid @RequestBody DeleteAccountRequest request) {
|
||||||
accountService.deleteAccount(principal.getIdAsLong(), request.password());
|
accountService.deleteAccount(principal.getIdAsLong(), request.password());
|
||||||
@@ -28,4 +37,29 @@ public class AccountController {
|
|||||||
authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
|
authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
|
||||||
return ResponseEntity.noContent().headers(headers).build();
|
return ResponseEntity.noContent().headers(headers).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/api/auth/password-reset", consumes = "application/json")
|
||||||
|
public ResponseEntity<Void> requestReset(@Valid @RequestBody ForgotPasswordRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
passwordResetService.requestReset(request.email(), ClientIp.of(httpRequest));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/api/auth/password-reset/confirm", consumes = "application/json")
|
||||||
|
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
passwordResetService.resetPassword(request.token(), request.password(), ClientIp.of(httpRequest));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under /api/auth only because the refresh cookie has that path; it tells which login stays.
|
||||||
|
@PostMapping(value = "/api/auth/password", consumes = "application/json")
|
||||||
|
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody ChangePasswordRequest request,
|
||||||
|
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false)
|
||||||
|
String refreshToken) {
|
||||||
|
accountService.changePassword(principal.getIdAsLong(), request.currentPassword(), request.newPassword(),
|
||||||
|
refreshToken);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package auth_service.modules.account.api;
|
package auth_service.modules.account.api;
|
||||||
|
|
||||||
import auth_service.modules.account.exception.InvalidPasswordException;
|
import auth_service.modules.account.exception.InvalidPasswordException;
|
||||||
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
|
import auth_service.modules.account.exception.LastAdminException;
|
||||||
|
import auth_service.modules.account.exception.PasswordResetInvalidException;
|
||||||
|
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -22,8 +24,20 @@ public class AccountExceptionController {
|
|||||||
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
|
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(TooManyDeleteAttemptsException.class)
|
@ExceptionHandler(PasswordResetInvalidException.class)
|
||||||
public ResponseEntity<ErrorResponse> clientError(TooManyDeleteAttemptsException ex) {
|
public ResponseEntity<ErrorResponse> clientError(PasswordResetInvalidException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.GONE).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new ErrorResponse("RESET_INVALID", ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(LastAdminException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(LastAdminException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new ErrorResponse("LAST_ADMIN", ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(TooManyAttemptsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(TooManyAttemptsException ex) {
|
||||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
|
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package auth_service.modules.account.api.dto.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public record ChangePasswordRequest(@NotBlank String currentPassword, @NotBlank @Size(min = 8) String newPassword) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt uses only the first 72 bytes and refuses a longer password.
|
||||||
|
*/
|
||||||
|
@AssertTrue(message = "The password may have at most 72 bytes")
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isPasswordShortEnough() {
|
||||||
|
return newPassword == null || newPassword.getBytes(StandardCharsets.UTF_8).length <= 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ChangePasswordRequest[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.account.api.dto.request;
|
||||||
|
|
||||||
|
import auth_service.shared.account.EmailAddress;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record ForgotPasswordRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email) {
|
||||||
|
|
||||||
|
public ForgotPasswordRequest {
|
||||||
|
email = EmailAddress.normalize(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package auth_service.modules.account.api.dto.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public record ResetPasswordRequest(@NotBlank @Size(max = 64) String token, @NotBlank @Size(min = 8) String password) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt uses only the first 72 bytes and refuses a longer password.
|
||||||
|
*/
|
||||||
|
@AssertTrue(message = "The password may have at most 72 bytes")
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isPasswordShortEnough() {
|
||||||
|
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ResetPasswordRequest[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.account.exception;
|
||||||
|
|
||||||
|
public class LastAdminException extends RuntimeException {
|
||||||
|
public LastAdminException() {
|
||||||
|
super("The last admin cannot delete the own account");
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.account.exception;
|
||||||
|
|
||||||
|
public class PasswordResetInvalidException extends RuntimeException {
|
||||||
|
public PasswordResetInvalidException() {
|
||||||
|
super("The reset link is unknown, used or expired.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.account.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class TooManyAttemptsException extends RuntimeException {
|
||||||
|
private final long retryAfterSeconds;
|
||||||
|
|
||||||
|
public TooManyAttemptsException(long retryAfterSeconds) {
|
||||||
|
super("Too many attempts. Try again later.");
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
package auth_service.modules.account.exception;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
public class TooManyDeleteAttemptsException extends RuntimeException {
|
|
||||||
private final long retryAfterSeconds;
|
|
||||||
|
|
||||||
public TooManyDeleteAttemptsException(long retryAfterSeconds) {
|
|
||||||
super("Too many wrong passwords. Try again later.");
|
|
||||||
this.retryAfterSeconds = retryAfterSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +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, String displayName, boolean blocked, Instant roleChangedAt,
|
||||||
|
boolean listed, boolean publicProfile, List<String> profileSections) {
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
package auth_service.modules.account.repo;
|
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.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.sql.Array;
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -47,6 +59,54 @@ public class AccountRepository {
|
|||||||
return flagged;
|
return flagged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<Long> findActiveIdByEmail(String email) {
|
||||||
|
return jdbc.queryForList("SELECT u.id FROM users u WHERE u.email = ? AND " + ActiveUser.of("u"), Long.class,
|
||||||
|
EmailAddress.normalize(email)).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an older reset link of the user.
|
||||||
|
*
|
||||||
|
* @return false when the user is not active
|
||||||
|
*/
|
||||||
|
public boolean storePasswordReset(long userId, String tokenHash, Instant expiresAt) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE users u SET password_reset_hash = ?, password_reset_expires_at = ?
|
||||||
|
WHERE u.id = ? AND\s""" + ActiveUser.of("u"), tokenHash, Timestamp.from(expiresAt), userId) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the password and uses up the link in one statement, so a link works once.
|
||||||
|
*
|
||||||
|
* @return the id of the user, empty when the link is unknown, used, expired or the user is not active
|
||||||
|
*/
|
||||||
|
public Optional<Long> resetPassword(String tokenHash, String passwordHash, Instant now) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
UPDATE users u SET password = ?, password_reset_hash = NULL, password_reset_expires_at = NULL
|
||||||
|
WHERE u.password_reset_hash = ? AND u.password_reset_expires_at > ? AND\s""" + ActiveUser.of("u")
|
||||||
|
+ " RETURNING u.id", Long.class, passwordHash, tokenHash, Timestamp.from(now)).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also ends an open reset link.
|
||||||
|
*/
|
||||||
|
public void updatePassword(long userId, String passwordHash) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE users SET password = ?, password_reset_hash = NULL, password_reset_expires_at = NULL
|
||||||
|
WHERE id = ?""", passwordHash, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends every login of the user but the one of keepTokenHash; null ends all. IS DISTINCT FROM, because for an
|
||||||
|
* unknown hash the subquery is NULL and a plain comparison would revoke nothing.
|
||||||
|
*/
|
||||||
|
public int revokeSessions(long userId, String keepTokenHash, Instant now) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL
|
||||||
|
AND family_id IS DISTINCT FROM (SELECT family_id FROM refresh_tokens WHERE token_hash = ? AND user_id = ?)""",
|
||||||
|
Timestamp.from(now), userId, keepTokenHash, userId);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Long> findFlaggedIds() {
|
public List<Long> findFlaggedIds() {
|
||||||
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
|
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
|
||||||
}
|
}
|
||||||
@@ -56,6 +116,45 @@ public class AccountRepository {
|
|||||||
Timestamp.from(cutoff));
|
Timestamp.from(cutoff));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
|
||||||
|
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(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every user, also the flagged ones.
|
||||||
|
*/
|
||||||
|
public List<CoreAccountState> findAllStates() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Also the flagged users: their rows in Core are no orphans yet.
|
* Also the flagged users: their rows in Core are no orphans yet.
|
||||||
*/
|
*/
|
||||||
@@ -72,4 +171,14 @@ public class AccountRepository {
|
|||||||
}
|
}
|
||||||
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
|
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings go by their ON DELETE CASCADE. registration_date is a naive UTC timestamp. A row whose resent link
|
||||||
|
* is still valid stays.
|
||||||
|
*/
|
||||||
|
public int deleteUnconfirmedBefore(LocalDateTime cutoff) {
|
||||||
|
return jdbc.update("""
|
||||||
|
DELETE FROM users WHERE status = 'UNCONFIRMED' AND registration_date < ?
|
||||||
|
AND (email_confirmation_expires_at IS NULL OR email_confirmation_expires_at < now())""", cutoff);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,18 +7,23 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nightly, after Core's sync: tells Core who is flagged, removes users flagged longer than the retention (Core
|
* Nightly, after Core's sync: tells Core who is flagged and every user's Steam ID and block, removes users flagged
|
||||||
* first, then Auth), and lets Core remove rows of users Auth does not know.
|
* longer than the retention (Core first, then Auth), and lets Core remove rows of users Auth does not know.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class AccountCleanupService {
|
public class AccountCleanupService {
|
||||||
|
|
||||||
|
static final String ZONE = "Europe/Berlin";
|
||||||
|
static final Duration UNCONFIRMED_RETENTION = Duration.ofDays(7);
|
||||||
|
|
||||||
private final AccountRepository accountRepository;
|
private final AccountRepository accountRepository;
|
||||||
private final CoreAccountService coreAccountService;
|
private final CoreAccountService coreAccountService;
|
||||||
private final boolean dryRun;
|
private final boolean dryRun;
|
||||||
@@ -33,18 +38,41 @@ public class AccountCleanupService {
|
|||||||
this.retention = retention;
|
this.retention = retention;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(cron = "${app.account-cleanup.cron}", zone = "Europe/Berlin")
|
@Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE)
|
||||||
public void runNightly() {
|
public void runNightly() {
|
||||||
|
purgeUnconfirmed();
|
||||||
try {
|
try {
|
||||||
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
|
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage());
|
log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sendStates();
|
||||||
purgeDue();
|
purgeDue();
|
||||||
pruneOrphans();
|
pruneOrphans();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not gated by dryRun: an unconfirmed row never was an account and has nothing in Core.
|
||||||
|
private void purgeUnconfirmed() {
|
||||||
|
try {
|
||||||
|
int deleted = accountRepository.deleteUnconfirmedBefore(
|
||||||
|
LocalDateTime.now(Clock.systemUTC()).minus(UNCONFIRMED_RETENTION));
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("Account cleanup: deleted {} unconfirmed registrations", deleted);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Account cleanup: purge of unconfirmed registrations failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendStates() {
|
||||||
|
try {
|
||||||
|
coreAccountService.replaceStates(accountRepository.findAllStates());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Account cleanup: Core did not take the Steam IDs and blocks: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void purgeDue() {
|
private void purgeDue() {
|
||||||
List<Long> due = accountRepository.findFlaggedBefore(Instant.now().minus(retention));
|
List<Long> due = accountRepository.findFlaggedBefore(Instant.now().minus(retention));
|
||||||
if (due.isEmpty()) {
|
if (due.isEmpty()) {
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
package auth_service.modules.account.service;
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
import auth_service.modules.account.exception.InvalidPasswordException;
|
import auth_service.modules.account.exception.InvalidPasswordException;
|
||||||
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
|
import auth_service.modules.account.exception.LastAdminException;
|
||||||
|
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||||
import auth_service.modules.account.repo.AccountRepository;
|
import auth_service.modules.account.repo.AccountRepository;
|
||||||
import auth_service.shared.account.AccountDeleter;
|
import auth_service.shared.account.AccountDeleter;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
|
import auth_service.shared.mail.AccountMailer;
|
||||||
import auth_service.shared.security.AttemptLimiter;
|
import auth_service.shared.security.AttemptLimiter;
|
||||||
|
import auth_service.shared.security.OpaqueToken;
|
||||||
import auth_service.shared.security.RequiresRole;
|
import auth_service.shared.security.RequiresRole;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionOperations;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -24,59 +33,113 @@ public class AccountService implements AccountDeleter {
|
|||||||
|
|
||||||
private static final Duration LOCK = Duration.ofMinutes(15);
|
private static final Duration LOCK = Duration.ofMinutes(15);
|
||||||
private static final int MAX_WRONG_PASSWORDS = 5;
|
private static final int MAX_WRONG_PASSWORDS = 5;
|
||||||
|
private static final ZoneId CLEANUP_ZONE = ZoneId.of(AccountCleanupService.ZONE);
|
||||||
|
|
||||||
private final AccountRepository accountRepository;
|
private final AccountRepository accountRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final CoreAccountService coreAccountService;
|
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);
|
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
|
||||||
|
|
||||||
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
|
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
|
||||||
CoreAccountService coreAccountService) {
|
CoreAccountService coreAccountService, AccountMailer accountMailer,
|
||||||
|
AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
|
||||||
|
@Value("${app.account-cleanup.retention}") Duration retention) {
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.coreAccountService = coreAccountService;
|
this.coreAccountService = coreAccountService;
|
||||||
|
this.accountMailer = accountMailer;
|
||||||
|
this.accountStatePublisher = accountStatePublisher;
|
||||||
|
this.transactions = transactions;
|
||||||
|
this.retention = retention;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core
|
* Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core
|
||||||
* hides the user at once; when Core is down, the nightly cleanup tells it.
|
* hides the user at once; when Core is down, the nightly cleanup tells it.
|
||||||
*/
|
*/
|
||||||
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
|
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
|
||||||
|
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||||
public void deleteAccount(long userId, String password) {
|
public void deleteAccount(long userId, String password) {
|
||||||
String key = String.valueOf(userId);
|
String key = acquireAttempt(userId);
|
||||||
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
|
|
||||||
throw new TooManyDeleteAttemptsException(LOCK.toSeconds());
|
|
||||||
}
|
|
||||||
Optional<String> hash = accountRepository.findPasswordHashOfActive(userId);
|
Optional<String> hash = accountRepository.findPasswordHashOfActive(userId);
|
||||||
if (hash.isEmpty()) {
|
if (hash.isEmpty()) {
|
||||||
// Already flagged, e.g. a second click: the answer is the same.
|
// Already flagged, e.g. a second click: the answer is the same.
|
||||||
attemptLimiter.release(key);
|
attemptLimiter.release(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!passwordEncoder.matches(password, hash.get())) {
|
checkPassword(key, password, hash.get());
|
||||||
throw new InvalidPasswordException();
|
|
||||||
}
|
|
||||||
attemptLimiter.reset(key);
|
|
||||||
flagAndTellCore(userId);
|
flagAndTellCore(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the login of refreshToken (may be null: then all logins end) and ends every other one.
|
||||||
|
*/
|
||||||
|
public void changePassword(long userId, String currentPassword, String newPassword, String refreshToken) {
|
||||||
|
String key = acquireAttempt(userId);
|
||||||
|
checkPassword(key, currentPassword,
|
||||||
|
accountRepository.findPasswordHashOfActive(userId).orElseThrow(InvalidPasswordException::new));
|
||||||
|
String hash = passwordEncoder.encode(newPassword);
|
||||||
|
transactions.executeWithoutResult(status -> {
|
||||||
|
accountRepository.updatePassword(userId, hash);
|
||||||
|
accountRepository.revokeSessions(userId, refreshToken == null ? null : OpaqueToken.hash(refreshToken),
|
||||||
|
Instant.now());
|
||||||
|
accountMailer.passwordChanged(userId);
|
||||||
|
});
|
||||||
|
log.info("Password of user {} changed", userId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@RequiresRole(Role.PLATFORM_ADMIN)
|
@RequiresRole(Role.PLATFORM_ADMIN)
|
||||||
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
|
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
|
||||||
|
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||||
public boolean deleteByAdmin(long userId) {
|
public boolean deleteByAdmin(long userId) {
|
||||||
return flagAndTellCore(userId);
|
return flagAndTellCore(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete account and change password share the limit: both guess the same password.
|
||||||
|
private String acquireAttempt(long userId) {
|
||||||
|
String key = String.valueOf(userId);
|
||||||
|
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
|
||||||
|
throw new TooManyAttemptsException(LOCK.toSeconds());
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkPassword(String key, String password, String hash) {
|
||||||
|
if (!passwordEncoder.matches(password, hash)) {
|
||||||
|
throw new InvalidPasswordException();
|
||||||
|
}
|
||||||
|
attemptLimiter.reset(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The flag and the mail commit together, and before Core is called.
|
||||||
private boolean flagAndTellCore(long userId) {
|
private boolean flagAndTellCore(long userId) {
|
||||||
if (accountRepository.flagDeleted(userId, Instant.now()) == 0) {
|
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;
|
||||||
|
}
|
||||||
|
accountMailer.accountDeleted(userId, LocalDate.ofInstant(now.plus(retention), CLEANUP_ZONE));
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
if (!flagged) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
log.info("Account of user {} flagged as deleted", userId);
|
log.info("Account of user {} flagged as deleted", userId);
|
||||||
try {
|
try {
|
||||||
coreAccountService.markDeleted(List.of(userId));
|
coreAccountService.markDeleted(List.of(userId));
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId,
|
log.warn("Core did not take the delete of user {}, the state push tries again: {}", userId,
|
||||||
e.getMessage());
|
e.getMessage());
|
||||||
|
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.account.service;
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
import auth_service.modules.account.model.CoreCleanupResult;
|
import auth_service.modules.account.model.CoreCleanupResult;
|
||||||
import auth_service.shared.security.ServiceTokenIssuer;
|
import auth_service.shared.security.ServiceTokenIssuer;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -52,6 +53,19 @@ public class CoreAccountService {
|
|||||||
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
|
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void applyStates(List<CoreAccountState> states) {
|
||||||
|
restClient.post().uri("/internal/accounts/states").headers(this::authorize)
|
||||||
|
.body(Map.of("accounts", states)).retrieve().toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After this, Core blocks exactly the blocked users of the list.
|
||||||
|
*/
|
||||||
|
public void replaceStates(List<CoreAccountState> states) {
|
||||||
|
restClient.put().uri("/internal/accounts/states").headers(this::authorize)
|
||||||
|
.body(Map.of("accounts", states)).retrieve().toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param dryRun Core only counts; Core also counts when its own dry run is on
|
* @param dryRun Core only counts; Core also counts when its own dry run is on
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
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. 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("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
|
||||||
|
public void publish(long userId) {
|
||||||
|
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
executor.execute(() -> send(userId, 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
executor.execute(() -> send(userId, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(long userId, int attempt) {
|
||||||
|
try {
|
||||||
|
// 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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
package auth_service.modules.admin.api;
|
package auth_service.modules.admin.api;
|
||||||
|
|
||||||
import auth_service.modules.admin.api.dto.request.BlockRequest;
|
import auth_service.modules.admin.api.dto.request.BlockRequest;
|
||||||
|
import auth_service.modules.admin.api.dto.request.DecisionRequest;
|
||||||
|
import auth_service.modules.admin.api.dto.request.RoleChangeRequest;
|
||||||
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
|
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
|
||||||
import auth_service.modules.admin.api.dto.response.AdminUserResponse;
|
import auth_service.modules.admin.api.dto.response.AdminUserResponse;
|
||||||
|
import auth_service.modules.admin.api.dto.response.PendingCountResponse;
|
||||||
|
import auth_service.modules.admin.api.dto.response.RoleChangeListResponse;
|
||||||
|
import auth_service.modules.admin.api.dto.response.RoleChangeResponse;
|
||||||
import auth_service.modules.admin.model.AdminUser;
|
import auth_service.modules.admin.model.AdminUser;
|
||||||
import auth_service.modules.admin.model.AdminUserPage;
|
import auth_service.modules.admin.model.AdminUserPage;
|
||||||
|
import auth_service.modules.admin.model.RoleChange;
|
||||||
import auth_service.modules.admin.service.AdminUserService;
|
import auth_service.modules.admin.service.AdminUserService;
|
||||||
|
import auth_service.modules.admin.service.RoleChangeService;
|
||||||
import auth_service.shared.security.UserPrincipal;
|
import auth_service.shared.security.UserPrincipal;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -14,6 +21,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -26,31 +34,78 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class AdminUserController {
|
public class AdminUserController {
|
||||||
|
|
||||||
private final AdminUserService adminUserService;
|
private final AdminUserService adminUserService;
|
||||||
|
private final RoleChangeService roleChangeService;
|
||||||
|
|
||||||
@GetMapping(version = "1.0")
|
@GetMapping
|
||||||
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
||||||
|
@RequestParam(defaultValue = "") String status,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
AdminUserPage found = adminUserService.findUsers(query, page, size);
|
AdminUserPage found = adminUserService.findUsers(query, status, page, size);
|
||||||
return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(),
|
return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(),
|
||||||
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{userId}/blocked", version = "1.0")
|
@GetMapping("/pending-count")
|
||||||
|
public ResponseEntity<PendingCountResponse> countPending() {
|
||||||
|
return ResponseEntity.ok(new PendingCountResponse(adminUserService.countPending()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/{userId}/decision")
|
||||||
|
public ResponseEntity<Void> decide(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
||||||
|
@Valid @RequestBody DecisionRequest request) {
|
||||||
|
adminUserService.decide(principal.getIdAsLong(), userId, request.accepted());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/{userId}/blocked")
|
||||||
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
||||||
@Valid @RequestBody BlockRequest request) {
|
@Valid @RequestBody BlockRequest request) {
|
||||||
adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked());
|
adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked());
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping(value = "/{userId}", version = "1.0")
|
@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) {
|
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
|
||||||
adminUserService.delete(principal.getIdAsLong(), userId);
|
adminUserService.delete(principal.getIdAsLong(), userId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DeleteMapping(value = "/{userId}/unconfirmed")
|
||||||
|
public ResponseEntity<Void> deleteUnconfirmed(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable long userId) {
|
||||||
|
adminUserService.deleteUnconfirmed(principal.getIdAsLong(), userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{userId}/password-reset")
|
||||||
|
public ResponseEntity<Void> sendPasswordReset(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable long userId) {
|
||||||
|
adminUserService.sendPasswordReset(principal.getIdAsLong(), userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
private static AdminUserResponse toApi(AdminUser user) {
|
private static AdminUserResponse toApi(AdminUser user) {
|
||||||
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
|
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
|
||||||
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt());
|
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
|
||||||
|
user.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RoleChangeResponse toApi(RoleChange change) {
|
||||||
|
return new RoleChangeResponse(change.actorUserId(), change.actorEmail(), change.oldRole().name(),
|
||||||
|
change.newRole().name(), change.changedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.admin.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record DecisionRequest(@NotNull Boolean accepted) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate,
|
public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate,
|
||||||
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) {
|
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt,
|
||||||
|
String status) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.admin.api.dto.response;
|
||||||
|
|
||||||
|
public record PendingCountResponse(long count) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.admin.api.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record RoleChangeListResponse(List<RoleChangeResponse> results) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth_service.modules.admin.api.dto.response;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param actorEmail null after the actor is purged
|
||||||
|
*/
|
||||||
|
public record RoleChangeResponse(long actorUserId, String actorEmail, String oldRole, String newRole,
|
||||||
|
Instant changedAt) {
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import lombok.Getter;
|
|||||||
@Getter
|
@Getter
|
||||||
public class AdminActionRefusedException extends RuntimeException {
|
public class AdminActionRefusedException extends RuntimeException {
|
||||||
/**
|
/**
|
||||||
* SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED
|
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED,
|
||||||
|
* NOT_UNCONFIRMED, NOT_ACTIVE, ACTOR_NOT_ADMIN, TARGET_IS_BLOCKED or ROLE_CHANGED
|
||||||
*/
|
*/
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param status UNCONFIRMED (address not confirmed yet), PENDING (registration waits for an admin), ACTIVE or DENIED
|
||||||
* @param deletedAt set while the account waits for the nightly cleanup
|
* @param deletedAt set while the account waits for the nightly cleanup
|
||||||
*/
|
*/
|
||||||
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
|
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
|
||||||
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) {
|
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt,
|
||||||
|
String status) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package auth_service.modules.admin.model;
|
||||||
|
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param actorEmail null after the actor is purged
|
||||||
|
*/
|
||||||
|
public record RoleChange(long actorUserId, String actorEmail, Role oldRole, Role newRole, Instant changedAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package auth_service.modules.admin.model;
|
||||||
|
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param deleted flagged as deleted, or denied
|
||||||
|
* @param roleChangedAt null when the role never changed on the admin page
|
||||||
|
*/
|
||||||
|
public record RoleTarget(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@ package auth_service.modules.admin.model;
|
|||||||
|
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
|
|
||||||
public record TargetState(Role role, boolean deleted) {
|
public record TargetState(Role role, boolean deleted, boolean pending, boolean unconfirmed) {
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ public class AdminUserRepository {
|
|||||||
|
|
||||||
// An empty query matches every user; % and _ in the query are searched as text.
|
// An empty query matches every user; % and _ in the query are searched as text.
|
||||||
private static final String MATCHES = "(? = '' OR email ILIKE ? ESCAPE '\\' OR display_name ILIKE ? ESCAPE '\\')";
|
private static final String MATCHES = "(? = '' OR email ILIKE ? ESCAPE '\\' OR display_name ILIKE ? ESCAPE '\\')";
|
||||||
|
private static final String HAS_STATUS = "(? = '' OR status = ?)";
|
||||||
|
|
||||||
private static final RowMapper<AdminUser> ROW = (rs, row) -> new AdminUser(
|
private static final RowMapper<AdminUser> ROW = (rs, row) -> new AdminUser(
|
||||||
rs.getLong("id"),
|
rs.getLong("id"),
|
||||||
@@ -32,7 +33,8 @@ public class AdminUserRepository {
|
|||||||
localDateTime(rs.getTimestamp("last_login_date")),
|
localDateTime(rs.getTimestamp("last_login_date")),
|
||||||
rs.getObject("steam_id") != null,
|
rs.getObject("steam_id") != null,
|
||||||
!rs.getBoolean("enabled"),
|
!rs.getBoolean("enabled"),
|
||||||
rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null);
|
rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null,
|
||||||
|
rs.getString("status"));
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -50,36 +52,71 @@ public class AdminUserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param query part of the email or display name, any case; empty for all users
|
* @param query part of the email or display name, any case; empty for all users
|
||||||
|
* @param status UNCONFIRMED, PENDING, ACTIVE or DENIED; empty for all
|
||||||
*/
|
*/
|
||||||
public List<AdminUser> findPage(String query, int page, int size) {
|
public List<AdminUser> findPage(String query, String status, int page, int size) {
|
||||||
Object[] match = matchArgs(query);
|
Object[] match = matchArgs(query);
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at
|
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at,
|
||||||
FROM users WHERE %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES), ROW,
|
status
|
||||||
match[0], match[1], match[2], size, (long) page * size);
|
FROM users WHERE %s AND %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES, HAS_STATUS), ROW,
|
||||||
|
match[0], match[1], match[2], status, status, size, (long) page * size);
|
||||||
}
|
}
|
||||||
|
|
||||||
public long count(String query) {
|
public long count(String query, String status) {
|
||||||
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES, Long.class, matchArgs(query));
|
Object[] match = matchArgs(query);
|
||||||
|
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES + " AND " + HAS_STATUS, Long.class,
|
||||||
|
match[0], match[1], match[2], status, status);
|
||||||
return count != null ? count : 0L;
|
return count != null ? count : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<TargetState> findState(long userId) {
|
public Optional<TargetState> findState(long userId) {
|
||||||
return jdbc.query("SELECT role, deleted_at FROM users WHERE id = ?",
|
return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?",
|
||||||
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null),
|
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
|
||||||
|
"PENDING".equals(rs.getString("status")), "UNCONFIRMED".equals(rs.getString("status"))),
|
||||||
userId).stream().findFirst();
|
userId).stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return false when the user is not pending (any more)
|
||||||
|
*/
|
||||||
|
public boolean accept(long userId) {
|
||||||
|
return jdbc.update("UPDATE users SET status = 'ACTIVE' WHERE id = ? AND status = 'PENDING'", userId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deleted_at hands the request to the nightly cleanup, like a deleted account.
|
||||||
|
*
|
||||||
|
* @return false when the user is not pending (any more)
|
||||||
|
*/
|
||||||
|
public boolean deny(long userId, Instant now) {
|
||||||
|
return jdbc.update("UPDATE users SET status = 'DENIED', deleted_at = ? WHERE id = ? AND status = 'PENDING'",
|
||||||
|
Timestamp.from(now), userId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard delete, like the nightly purge: the address is free at once. Settings go by their ON DELETE CASCADE.
|
||||||
|
*
|
||||||
|
* @return false when the user is not unconfirmed (any more)
|
||||||
|
*/
|
||||||
|
public boolean deleteUnconfirmed(long userId) {
|
||||||
|
return jdbc.update("DELETE FROM users WHERE id = ? AND status = 'UNCONFIRMED'", userId) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocking also ends every login of the user.
|
* Blocking also ends every login of the user.
|
||||||
|
*
|
||||||
|
* @return false when the user already had this state
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void setBlocked(long userId, boolean blocked, Instant now) {
|
public boolean setBlocked(long userId, boolean blocked, Instant now) {
|
||||||
jdbc.update("UPDATE users SET enabled = ? WHERE id = ?", !blocked, userId);
|
boolean changed = jdbc.update("UPDATE users SET enabled = ? WHERE id = ? AND enabled = ?", !blocked, userId,
|
||||||
|
blocked) > 0;
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
|
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
|
||||||
Timestamp.from(now), userId);
|
Timestamp.from(now), userId);
|
||||||
}
|
}
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,16 @@ import auth_service.modules.admin.model.AdminUserPage;
|
|||||||
import auth_service.modules.admin.model.TargetState;
|
import auth_service.modules.admin.model.TargetState;
|
||||||
import auth_service.modules.admin.repo.AdminUserRepository;
|
import auth_service.modules.admin.repo.AdminUserRepository;
|
||||||
import auth_service.shared.account.AccountDeleter;
|
import auth_service.shared.account.AccountDeleter;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
|
import auth_service.shared.account.PasswordResetSender;
|
||||||
import auth_service.shared.audit.AdminAuditLog;
|
import auth_service.shared.audit.AdminAuditLog;
|
||||||
|
import auth_service.shared.mail.AccountMailer;
|
||||||
import auth_service.shared.security.RequiresRole;
|
import auth_service.shared.security.RequiresRole;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -28,25 +32,58 @@ public class AdminUserService {
|
|||||||
private final AdminUserRepository adminUserRepository;
|
private final AdminUserRepository adminUserRepository;
|
||||||
private final AdminAuditLog adminAuditLog;
|
private final AdminAuditLog adminAuditLog;
|
||||||
private final AccountDeleter accountDeleter;
|
private final AccountDeleter accountDeleter;
|
||||||
|
private final AccountStatePublisher accountStatePublisher;
|
||||||
|
private final AccountMailer accountMailer;
|
||||||
|
private final PasswordResetSender passwordResetSender;
|
||||||
|
|
||||||
public AdminUserPage findUsers(String query, int page, int size) {
|
public AdminUserPage findUsers(String query, String status, int page, int size) {
|
||||||
String search = query == null ? "" : query.trim();
|
String search = query == null ? "" : query.trim();
|
||||||
|
String withStatus = status == null ? "" : status;
|
||||||
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
|
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
|
||||||
int pageNumber = Math.max(page, 0);
|
int pageNumber = Math.max(page, 0);
|
||||||
long total = adminUserRepository.count(search);
|
long total = adminUserRepository.count(search, withStatus);
|
||||||
return new AdminUserPage(adminUserRepository.findPage(search, pageNumber, pageSize), pageNumber, pageSize, total,
|
return new AdminUserPage(adminUserRepository.findPage(search, withStatus, pageNumber, pageSize), pageNumber,
|
||||||
(int) Math.ceil((double) total / pageSize));
|
pageSize, total, (int) Math.ceil((double) total / pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countPending() {
|
||||||
|
return adminUserRepository.count("", "PENDING");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
|
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
|
||||||
|
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||||
|
public void decide(long actorUserId, long targetUserId, boolean accepted) {
|
||||||
|
checkedTarget(actorUserId, targetUserId);
|
||||||
|
boolean changed = accepted ? adminUserRepository.accept(targetUserId)
|
||||||
|
: adminUserRepository.deny(targetUserId, Instant.now());
|
||||||
|
if (!changed) {
|
||||||
|
throw new AdminActionRefusedException("NOT_PENDING", "The registration is already decided");
|
||||||
|
}
|
||||||
|
adminAuditLog.record(actorUserId, accepted ? "ACCEPT" : "DENY", targetUserId, null);
|
||||||
|
if (accepted) {
|
||||||
|
accountMailer.accountAccepted(targetUserId);
|
||||||
|
} else {
|
||||||
|
accountMailer.registrationDenied(targetUserId);
|
||||||
|
}
|
||||||
|
log.info("Admin {} {} the registration of user {}", actorUserId, accepted ? "accepted" : "denied", targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
|
||||||
|
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||||
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
|
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
|
||||||
TargetState target = checkedTarget(actorUserId, targetUserId);
|
TargetState target = notARequest(checkedTarget(actorUserId, targetUserId));
|
||||||
if (target.deleted()) {
|
if (target.deleted()) {
|
||||||
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
||||||
}
|
}
|
||||||
adminUserRepository.setBlocked(targetUserId, blocked, Instant.now());
|
boolean changed = adminUserRepository.setBlocked(targetUserId, blocked, Instant.now());
|
||||||
|
accountStatePublisher.publish(targetUserId);
|
||||||
adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null);
|
adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null);
|
||||||
|
// A second click must not mail again.
|
||||||
|
if (blocked && changed) {
|
||||||
|
accountMailer.accountBlocked(targetUserId);
|
||||||
|
}
|
||||||
log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId);
|
log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +92,40 @@ public class AdminUserService {
|
|||||||
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
|
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
|
||||||
*/
|
*/
|
||||||
public void delete(long actorUserId, long targetUserId) {
|
public void delete(long actorUserId, long targetUserId) {
|
||||||
checkedTarget(actorUserId, targetUserId);
|
notARequest(checkedTarget(actorUserId, targetUserId));
|
||||||
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
||||||
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
||||||
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only for a request whose address was never confirmed: a hard delete, no mail. The nightly purge does the same
|
||||||
|
* after 7 days.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteUnconfirmed(long actorUserId, long targetUserId) {
|
||||||
|
if (!checkedTarget(actorUserId, targetUserId).unconfirmed()
|
||||||
|
|| !adminUserRepository.deleteUnconfirmed(targetUserId)) {
|
||||||
|
throw new AdminActionRefusedException("NOT_UNCONFIRMED", "The registration is confirmed already");
|
||||||
|
}
|
||||||
|
adminAuditLog.record(actorUserId, "DELETE_UNCONFIRMED", targetUserId, null);
|
||||||
|
log.info("Admin {} deleted the unconfirmed registration of user {}", actorUserId, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same mail as "Forgot password". The admin never sees or sets a password.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void sendPasswordReset(long actorUserId, long targetUserId) {
|
||||||
|
checkedTarget(actorUserId, targetUserId);
|
||||||
|
if (!passwordResetSender.sendByAdmin(targetUserId)) {
|
||||||
|
throw new AdminActionRefusedException("NOT_ACTIVE", "Only an active account can get a reset mail");
|
||||||
|
}
|
||||||
|
adminAuditLog.record(actorUserId, "SEND_PASSWORD_RESET", targetUserId, null);
|
||||||
|
log.info("Admin {} sent a password reset to user {}", actorUserId, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
private TargetState checkedTarget(long actorUserId, long targetUserId) {
|
private TargetState checkedTarget(long actorUserId, long targetUserId) {
|
||||||
if (actorUserId == targetUserId) {
|
if (actorUserId == targetUserId) {
|
||||||
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
|
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
|
||||||
@@ -73,4 +137,14 @@ public class AdminUserService {
|
|||||||
}
|
}
|
||||||
return target;
|
return 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;
|
package auth_service.modules.hint.api;
|
||||||
|
|
||||||
|
import auth_service.modules.hint.api.dto.request.AutoHintRequest;
|
||||||
import auth_service.modules.hint.api.dto.request.CreateHintRequest;
|
import auth_service.modules.hint.api.dto.request.CreateHintRequest;
|
||||||
import auth_service.modules.hint.api.dto.response.AdminHintListResponse;
|
import auth_service.modules.hint.api.dto.response.AdminHintListResponse;
|
||||||
import auth_service.modules.hint.api.dto.response.AdminHintResponse;
|
import auth_service.modules.hint.api.dto.response.AdminHintResponse;
|
||||||
|
import auth_service.modules.hint.api.dto.response.AutoHintResponse;
|
||||||
import auth_service.modules.hint.api.dto.response.CreatedHintResponse;
|
import auth_service.modules.hint.api.dto.response.CreatedHintResponse;
|
||||||
import auth_service.modules.hint.model.AdminHint;
|
import auth_service.modules.hint.model.AdminHint;
|
||||||
import auth_service.modules.hint.model.Hint;
|
import auth_service.modules.hint.model.Hint;
|
||||||
|
import auth_service.modules.hint.model.HintTarget;
|
||||||
import auth_service.modules.hint.model.NewHint;
|
import auth_service.modules.hint.model.NewHint;
|
||||||
import auth_service.modules.hint.service.AdminHintService;
|
import auth_service.modules.hint.service.AdminHintService;
|
||||||
import auth_service.shared.security.UserPrincipal;
|
import auth_service.shared.security.UserPrincipal;
|
||||||
@@ -29,29 +32,43 @@ public class AdminHintController {
|
|||||||
|
|
||||||
private final AdminHintService adminHintService;
|
private final AdminHintService adminHintService;
|
||||||
|
|
||||||
@GetMapping(version = "1.0")
|
@GetMapping
|
||||||
public ResponseEntity<AdminHintListResponse> findAll() {
|
public ResponseEntity<AdminHintListResponse> findAll() {
|
||||||
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
|
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
|
||||||
.map(AdminHintController::toApi).toList()));
|
.map(AdminHintController::toApi).toList(),
|
||||||
|
adminHintService.findAutoHints().stream()
|
||||||
|
.map(state -> new AutoHintResponse(state.key().name(), state.enabled())).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(version = "1.0")
|
@PostMapping
|
||||||
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody CreateHintRequest request) {
|
@Valid @RequestBody CreateHintRequest request) {
|
||||||
long id = adminHintService.create(principal.getIdAsLong(),
|
long id = adminHintService.create(principal.getIdAsLong(),
|
||||||
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt()));
|
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt(),
|
||||||
|
new HintTarget(Boolean.TRUE.equals(request.targetNoSteam()), request.targetRole(),
|
||||||
|
request.targetNewDays(), Boolean.TRUE.equals(request.targetEmptyLibrary()))));
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
|
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{hintId}/end", version = "1.0")
|
@PutMapping(value = "/{hintId}/end")
|
||||||
public ResponseEntity<Void> end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
public ResponseEntity<Void> end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
||||||
adminHintService.end(principal.getIdAsLong(), hintId);
|
adminHintService.end(principal.getIdAsLong(), hintId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/auto/{key}")
|
||||||
|
public ResponseEntity<Void> setAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key,
|
||||||
|
@Valid @RequestBody AutoHintRequest request) {
|
||||||
|
adminHintService.setAutoHintEnabled(principal.getIdAsLong(), key, request.enabled());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
private static AdminHintResponse toApi(AdminHint admin) {
|
private static AdminHintResponse toApi(AdminHint admin) {
|
||||||
Hint hint = admin.hint();
|
Hint hint = admin.hint();
|
||||||
|
HintTarget target = hint.target();
|
||||||
return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
|
return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
|
||||||
hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount());
|
hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount(),
|
||||||
|
target.noSteam(), target.role() == null ? null : target.role().name(), target.newDays(),
|
||||||
|
target.emptyLibrary());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package auth_service.modules.hint.api;
|
|||||||
|
|
||||||
import auth_service.modules.hint.api.dto.response.HintListResponse;
|
import auth_service.modules.hint.api.dto.response.HintListResponse;
|
||||||
import auth_service.modules.hint.api.dto.response.HintResponse;
|
import auth_service.modules.hint.api.dto.response.HintResponse;
|
||||||
|
import auth_service.modules.hint.model.AutoHint;
|
||||||
import auth_service.modules.hint.model.Hint;
|
import auth_service.modules.hint.model.Hint;
|
||||||
import auth_service.modules.hint.service.HintService;
|
import auth_service.modules.hint.service.HintService;
|
||||||
import auth_service.shared.security.UserPrincipal;
|
import auth_service.shared.security.UserPrincipal;
|
||||||
@@ -21,13 +22,28 @@ public class HintController {
|
|||||||
|
|
||||||
private final HintService hintService;
|
private final HintService hintService;
|
||||||
|
|
||||||
@GetMapping(version = "1.0")
|
@GetMapping
|
||||||
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream()
|
long userId = principal.getIdAsLong();
|
||||||
.map(HintController::toApi).toList()));
|
return ResponseEntity.ok(new HintListResponse(
|
||||||
|
hintService.activeHints(userId, principal.role()).stream().map(HintController::toApi).toList(),
|
||||||
|
hintService.autoHints(userId).stream().map(AutoHint::name).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{hintId}/done", version = "1.0")
|
@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")
|
||||||
public ResponseEntity<Void> markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
public ResponseEntity<Void> markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
||||||
hintService.markDone(principal.getIdAsLong(), hintId);
|
hintService.markDone(principal.getIdAsLong(), hintId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
@@ -35,6 +51,6 @@ public class HintController {
|
|||||||
|
|
||||||
private static HintResponse toApi(Hint hint) {
|
private static HintResponse toApi(Hint hint) {
|
||||||
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
|
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
|
||||||
hint.endsAt());
|
hint.endsAt(), hint.target().emptyLibrary());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.hint.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AutoHintRequest(@NotNull Boolean enabled) {
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package auth_service.modules.hint.api.dto.request;
|
package auth_service.modules.hint.api.dto.request;
|
||||||
|
|
||||||
import auth_service.modules.hint.model.HintKind;
|
import auth_service.modules.hint.model.HintKind;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
@@ -8,8 +11,12 @@ import jakarta.validation.constraints.Size;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plain text, never read as HTML.
|
* Plain text with {@code **bold**} and {@code [text](url)}; the Frontend renders it, never as HTML.
|
||||||
|
* Target fields are optional, missing = every user. Wrappers, because a missing primitive is a 400.
|
||||||
|
* {@code targetRole} means this role and higher.
|
||||||
*/
|
*/
|
||||||
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
|
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
|
||||||
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt) {
|
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt,
|
||||||
|
Boolean targetNoSteam, Role targetRole, @Min(1) @Max(365) Integer targetNewDays,
|
||||||
|
Boolean targetEmptyLibrary) {
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,5 @@ package auth_service.modules.hint.api.dto.response;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record AdminHintListResponse(List<AdminHintResponse> results) {
|
public record AdminHintListResponse(List<AdminHintResponse> results, List<AutoHintResponse> automatic) {
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,7 @@ package auth_service.modules.hint.api.dto.response;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
|
public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
|
||||||
String status, long createdBy, Instant createdAt, long doneCount) {
|
String status, long createdBy, Instant createdAt, long doneCount,
|
||||||
|
boolean targetNoSteam, String targetRole, Integer targetNewDays,
|
||||||
|
boolean targetEmptyLibrary) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.hint.api.dto.response;
|
||||||
|
|
||||||
|
public record AutoHintResponse(String key, boolean enabled) {
|
||||||
|
}
|
||||||
@@ -2,5 +2,8 @@ package auth_service.modules.hint.api.dto.response;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record HintListResponse(List<HintResponse> results) {
|
/**
|
||||||
|
* @param automatic keys of the automatic hints that are on and not hidden for this user
|
||||||
|
*/
|
||||||
|
public record HintListResponse(List<HintResponse> results, List<String> automatic) {
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,6 @@ package auth_service.modules.hint.api.dto.response;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt) {
|
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
|
||||||
|
boolean emptyLibraryOnly) {
|
||||||
}
|
}
|
||||||
@@ -4,4 +4,8 @@ public class HintNotFoundException extends RuntimeException {
|
|||||||
public HintNotFoundException(long hintId) {
|
public HintNotFoundException(long hintId) {
|
||||||
super("No hint with id " + hintId);
|
super("No hint with id " + hintId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HintNotFoundException(String key) {
|
||||||
|
super("No automatic hint " + key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package auth_service.modules.hint.model;
|
||||||
|
|
||||||
|
import auth_service.modules.hint.exception.HintNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in hints. The Frontend owns the texts and the conditions.
|
||||||
|
*/
|
||||||
|
public enum AutoHint {
|
||||||
|
NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE;
|
||||||
|
|
||||||
|
public static AutoHint parse(String key) {
|
||||||
|
try {
|
||||||
|
return valueOf(key);
|
||||||
|
} catch (IllegalArgumentException _) {
|
||||||
|
throw new HintNotFoundException(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.hint.model;
|
||||||
|
|
||||||
|
public record AutoHintState(AutoHint key, boolean enabled) {
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ import java.time.Instant;
|
|||||||
/**
|
/**
|
||||||
* @param textDe null: German users see {@code textEn}
|
* @param textDe null: German users see {@code textEn}
|
||||||
*/
|
*/
|
||||||
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) {
|
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
|
||||||
|
HintTarget target) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package auth_service.modules.hint.model;
|
||||||
|
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target groups of a hint, combined with AND.
|
||||||
|
*
|
||||||
|
* @param role null: every role; else this role and higher (by the active role)
|
||||||
|
* @param newDays null: any registration date; else registered less than this many days ago
|
||||||
|
* @param emptyLibrary checked by the Frontend with Core's library summary
|
||||||
|
*/
|
||||||
|
public record HintTarget(boolean noSteam, Role role, Integer newDays, boolean emptyLibrary) {
|
||||||
|
|
||||||
|
public static final HintTarget ALL = new HintTarget(false, null, null, false);
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ package auth_service.modules.hint.model;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt) {
|
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
|
||||||
|
HintTarget target) {
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,31 @@
|
|||||||
package auth_service.modules.hint.repo;
|
package auth_service.modules.hint.repo;
|
||||||
|
|
||||||
import auth_service.modules.hint.model.AdminHint;
|
import auth_service.modules.hint.model.AdminHint;
|
||||||
|
import auth_service.modules.hint.model.AutoHint;
|
||||||
|
import auth_service.modules.hint.model.AutoHintState;
|
||||||
import auth_service.modules.hint.model.Hint;
|
import auth_service.modules.hint.model.Hint;
|
||||||
import auth_service.modules.hint.model.HintKind;
|
import auth_service.modules.hint.model.HintKind;
|
||||||
import auth_service.modules.hint.model.HintStatus;
|
import auth_service.modules.hint.model.HintStatus;
|
||||||
|
import auth_service.modules.hint.model.HintTarget;
|
||||||
import auth_service.modules.hint.model.NewHint;
|
import auth_service.modules.hint.model.NewHint;
|
||||||
|
import auth_service.shared.security.Role;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class HintRepository {
|
public class HintRepository {
|
||||||
|
|
||||||
private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at";
|
private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at, "
|
||||||
|
+ "h.target_no_steam, h.target_role, h.target_new_days, h.target_empty_library";
|
||||||
|
|
||||||
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
|
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
|
||||||
rs.getLong("id"),
|
rs.getLong("id"),
|
||||||
@@ -25,7 +33,9 @@ public class HintRepository {
|
|||||||
rs.getString("text_de"),
|
rs.getString("text_de"),
|
||||||
HintKind.valueOf(rs.getString("kind")),
|
HintKind.valueOf(rs.getString("kind")),
|
||||||
rs.getTimestamp("starts_at").toInstant(),
|
rs.getTimestamp("starts_at").toInstant(),
|
||||||
rs.getTimestamp("ends_at").toInstant());
|
rs.getTimestamp("ends_at").toInstant(),
|
||||||
|
new HintTarget(rs.getBoolean("target_no_steam"), role(rs.getString("target_role")),
|
||||||
|
rs.getObject("target_new_days", Integer.class), rs.getBoolean("target_empty_library")));
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -33,13 +43,56 @@ public class HintRepository {
|
|||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Hint> findActiveFor(long userId, Instant now) {
|
/**
|
||||||
|
* @param role the active role of the caller; a target role matches this role and higher
|
||||||
|
*/
|
||||||
|
public List<Hint> findActiveFor(long userId, Role role, Instant now) {
|
||||||
Timestamp at = Timestamp.from(now);
|
Timestamp at = Timestamp.from(now);
|
||||||
|
// registration_date is naive UTC
|
||||||
|
LocalDateTime utc = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
SELECT %s FROM hint h
|
SELECT %s FROM hint h JOIN users u ON u.id = ?
|
||||||
WHERE h.starts_at <= ? AND h.ends_at > ?
|
WHERE h.starts_at <= ? AND h.ends_at > ?
|
||||||
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = ?)
|
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = u.id)
|
||||||
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, at, at, userId);
|
AND (NOT h.target_no_steam OR u.steam_id IS NULL)
|
||||||
|
AND (h.target_role IS NULL
|
||||||
|
OR CASE h.target_role WHEN 'USER' THEN 0 WHEN 'AUDITOR' THEN 1 ELSE 2 END <= ?)
|
||||||
|
AND (h.target_new_days IS NULL
|
||||||
|
OR u.registration_date > ?::timestamp - make_interval(days => h.target_new_days))
|
||||||
|
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, userId, at, at, role.ordinal(),
|
||||||
|
utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AutoHint> findVisibleAutoHints(long userId, Instant now) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT a.hint_key FROM auto_hint a
|
||||||
|
WHERE a.enabled
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM auto_hint_hidden x
|
||||||
|
WHERE x.user_id = ? AND x.hint_key = a.hint_key
|
||||||
|
AND (x.hidden_until IS NULL OR x.hidden_until > ?))""",
|
||||||
|
(rs, row) -> AutoHint.valueOf(rs.getString("hint_key")), userId, Timestamp.from(now))
|
||||||
|
.stream().sorted().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param until null: for good. A snooze never undoes "for good".
|
||||||
|
*/
|
||||||
|
public void hideAutoHint(long userId, AutoHint key, Instant until) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO auto_hint_hidden (user_id, hint_key, hidden_until) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (user_id, hint_key) DO UPDATE SET hidden_until =
|
||||||
|
CASE WHEN auto_hint_hidden.hidden_until IS NULL THEN NULL ELSE EXCLUDED.hidden_until END""",
|
||||||
|
userId, key.name(), until == null ? null : Timestamp.from(until));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AutoHintState> findAutoHints() {
|
||||||
|
return jdbc.query("SELECT hint_key, enabled FROM auto_hint",
|
||||||
|
(rs, row) -> new AutoHintState(AutoHint.valueOf(rs.getString("hint_key")), rs.getBoolean("enabled")))
|
||||||
|
.stream().sorted(Comparator.comparing(AutoHintState::key)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAutoHintEnabled(AutoHint key, boolean enabled) {
|
||||||
|
jdbc.update("UPDATE auto_hint SET enabled = ? WHERE hint_key = ?", enabled, key.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AdminHint> findAll(Instant now) {
|
public List<AdminHint> findAll(Instant now) {
|
||||||
@@ -61,10 +114,14 @@ public class HintRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public long insert(NewHint hint, long createdBy) {
|
public long insert(NewHint hint, long createdBy) {
|
||||||
|
HintTarget target = hint.target();
|
||||||
return Objects.requireNonNull(jdbc.queryForObject("""
|
return Objects.requireNonNull(jdbc.queryForObject("""
|
||||||
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by) VALUES (?, ?, ?, ?, ?, ?)
|
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by, target_no_steam, target_role,
|
||||||
|
target_new_days, target_empty_library)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
|
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
|
||||||
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy));
|
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy, target.noSteam(),
|
||||||
|
target.role() == null ? null : target.role().name(), target.newDays(), target.emptyLibrary()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDone(long hintId, long userId) {
|
public void markDone(long hintId, long userId) {
|
||||||
@@ -81,4 +138,9 @@ public class HintRepository {
|
|||||||
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
|
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
|
||||||
at, at, hintId, at) > 0;
|
at, at, hintId, at) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not Role.parse: it turns null into USER, and null here means "every role".
|
||||||
|
private static Role role(String name) {
|
||||||
|
return name == null ? null : Role.valueOf(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@ package auth_service.modules.hint.service;
|
|||||||
import auth_service.modules.hint.exception.HintNotFoundException;
|
import auth_service.modules.hint.exception.HintNotFoundException;
|
||||||
import auth_service.modules.hint.exception.InvalidHintException;
|
import auth_service.modules.hint.exception.InvalidHintException;
|
||||||
import auth_service.modules.hint.model.AdminHint;
|
import auth_service.modules.hint.model.AdminHint;
|
||||||
|
import auth_service.modules.hint.model.AutoHint;
|
||||||
|
import auth_service.modules.hint.model.AutoHintState;
|
||||||
import auth_service.modules.hint.model.NewHint;
|
import auth_service.modules.hint.model.NewHint;
|
||||||
import auth_service.modules.hint.repo.HintRepository;
|
import auth_service.modules.hint.repo.HintRepository;
|
||||||
import auth_service.shared.audit.AdminAuditLog;
|
import auth_service.shared.audit.AdminAuditLog;
|
||||||
@@ -38,8 +40,8 @@ public class AdminHintService {
|
|||||||
throw new InvalidHintException("The end must be in the future");
|
throw new InvalidHintException("The end must be in the future");
|
||||||
}
|
}
|
||||||
String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe();
|
String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe();
|
||||||
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt()),
|
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt(),
|
||||||
actorUserId);
|
hint.target()), actorUserId);
|
||||||
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
|
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
|
||||||
log.info("Admin {} created hint {}", actorUserId, id);
|
log.info("Admin {} created hint {}", actorUserId, id);
|
||||||
return id;
|
return id;
|
||||||
@@ -55,4 +57,16 @@ public class AdminHintService {
|
|||||||
log.info("Admin {} ended hint {}", actorUserId, hintId);
|
log.info("Admin {} ended hint {}", actorUserId, hintId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AutoHintState> findAutoHints() {
|
||||||
|
return hintRepository.findAutoHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void setAutoHintEnabled(long actorUserId, String key, boolean enabled) {
|
||||||
|
AutoHint hint = AutoHint.parse(key);
|
||||||
|
hintRepository.setAutoHintEnabled(hint, enabled);
|
||||||
|
adminAuditLog.record(actorUserId, enabled ? "AUTO_HINT_ON" : "AUTO_HINT_OFF", null, hint.name());
|
||||||
|
log.info("Admin {} turned automatic hint {} {}", actorUserId, hint, enabled ? "on" : "off");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package auth_service.modules.hint.service;
|
package auth_service.modules.hint.service;
|
||||||
|
|
||||||
import auth_service.modules.hint.exception.HintNotFoundException;
|
import auth_service.modules.hint.exception.HintNotFoundException;
|
||||||
|
import auth_service.modules.hint.model.AutoHint;
|
||||||
import auth_service.modules.hint.model.Hint;
|
import auth_service.modules.hint.model.Hint;
|
||||||
import auth_service.modules.hint.repo.HintRepository;
|
import auth_service.modules.hint.repo.HintRepository;
|
||||||
import auth_service.shared.security.RequiresRole;
|
import auth_service.shared.security.RequiresRole;
|
||||||
@@ -8,6 +9,7 @@ import auth_service.shared.security.Role;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -16,10 +18,24 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class HintService {
|
public class HintService {
|
||||||
|
|
||||||
|
private static final Duration SNOOZE = Duration.ofDays(7);
|
||||||
|
|
||||||
private final HintRepository hintRepository;
|
private final HintRepository hintRepository;
|
||||||
|
|
||||||
public List<Hint> activeHints(long userId) {
|
public List<Hint> activeHints(long userId, Role role) {
|
||||||
return hintRepository.findActiveFor(userId, Instant.now());
|
return hintRepository.findActiveFor(userId, role, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AutoHint> autoHints(long userId) {
|
||||||
|
return hintRepository.findVisibleAutoHints(userId, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void snoozeAutoHint(long userId, String key) {
|
||||||
|
hintRepository.hideAutoHint(userId, AutoHint.parse(key), Instant.now().plus(SNOOZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void hideAutoHintForGood(long userId, String key) {
|
||||||
|
hintRepository.hideAutoHint(userId, AutoHint.parse(key), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDone(long userId, long hintId) {
|
public void markDone(long userId, long hintId) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth_service.modules.login.api;
|
|||||||
|
|
||||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
import auth_service.modules.login.exception.AccountBlockedException;
|
import auth_service.modules.login.exception.AccountBlockedException;
|
||||||
|
import auth_service.modules.login.exception.AccountPendingException;
|
||||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.RoleNotAllowedException;
|
import auth_service.modules.login.exception.RoleNotAllowedException;
|
||||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
@@ -45,6 +46,11 @@ public class LoginExceptionController {
|
|||||||
return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccountPendingException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(AccountPendingException ex) {
|
||||||
|
return response(new ErrorResponse("ACCOUNT_PENDING", ex.getMessage()), HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(RoleNotAllowedException.class)
|
@ExceptionHandler(RoleNotAllowedException.class)
|
||||||
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
|
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
|
||||||
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.login.api.dto.request;
|
package auth_service.modules.login.api.dto.request;
|
||||||
|
|
||||||
|
import auth_service.shared.account.EmailAddress;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@@ -7,4 +8,8 @@ import jakarta.validation.constraints.NotNull;
|
|||||||
* DTO (Data Transfer Object) representing the incoming login request.
|
* DTO (Data Transfer Object) representing the incoming login request.
|
||||||
*/
|
*/
|
||||||
public record LoginRequest(@NotNull String email, @NotNull @NotBlank String password) {
|
public record LoginRequest(@NotNull String email, @NotNull @NotBlank String password) {
|
||||||
|
|
||||||
|
public LoginRequest {
|
||||||
|
email = EmailAddress.normalize(email);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.login.exception;
|
||||||
|
|
||||||
|
public class AccountPendingException extends RuntimeException {
|
||||||
|
public AccountPendingException() {
|
||||||
|
super("An admin has not accepted this account yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,26 @@ package auth_service.modules.login.model;
|
|||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
@Data
|
@Data
|
||||||
public class User {
|
public class User {
|
||||||
private long id;
|
private long id;
|
||||||
private String email;
|
private String email;
|
||||||
|
@ToString.Exclude
|
||||||
private String password;
|
private String password;
|
||||||
private Long steamId;
|
private Long steamId;
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
private boolean deleted;
|
private boolean deleted;
|
||||||
|
private boolean pending;
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Role role = Role.USER;
|
private Role role = Role.USER;
|
||||||
|
private Instant roleChangedAt;
|
||||||
|
|
||||||
public boolean canLogin() {
|
public boolean canLogin() {
|
||||||
return enabled && !deleted;
|
return enabled && !deleted && !pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import java.util.Optional;
|
|||||||
public interface MinimalUserRepository {
|
public interface MinimalUserRepository {
|
||||||
/**
|
/**
|
||||||
* Finds a user by their email address.
|
* Finds a user by their email address.
|
||||||
* * @param email The email to search for
|
* @param email The email to search for
|
||||||
*
|
*
|
||||||
* @return An Optional containing the user if found
|
* @return An Optional containing the user if found
|
||||||
*/
|
*/
|
||||||
@@ -18,5 +18,11 @@ public interface MinimalUserRepository {
|
|||||||
|
|
||||||
Optional<User> findById(long userId);
|
Optional<User> findById(long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the password hash and holds a lock on the row until the transaction ends, so a password change commits
|
||||||
|
* before or after the caller's writes, never between.
|
||||||
|
*/
|
||||||
|
Optional<String> lockPasswordHash(long userId);
|
||||||
|
|
||||||
void updateLoginDate(long userId);
|
void updateLoginDate(long userId);
|
||||||
}
|
}
|
||||||
+33
-28
@@ -1,12 +1,11 @@
|
|||||||
package auth_service.modules.login.persistence;
|
package auth_service.modules.login.persistence;
|
||||||
|
|
||||||
import auth_service.modules.login.model.User;
|
import auth_service.modules.login.model.User;
|
||||||
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
import auth_service.shared.account.EmailAddress;
|
||||||
import auth_service.modules.login.persistence.mapper.EntityMapper;
|
import auth_service.shared.security.Role;
|
||||||
import jakarta.persistence.EntityManager;
|
import lombok.RequiredArgsConstructor;
|
||||||
import jakarta.persistence.PersistenceContext;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import jakarta.persistence.TypedQuery;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import jakarta.transaction.Transactional;
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -14,39 +13,45 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@Transactional
|
@RequiredArgsConstructor
|
||||||
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
|
||||||
@PersistenceContext
|
|
||||||
private EntityManager entityManager;
|
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, role_changed_at FROM users ";
|
||||||
|
|
||||||
|
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
|
||||||
|
.id(rs.getLong("id"))
|
||||||
|
.email(rs.getString("email"))
|
||||||
|
.password(rs.getString("password"))
|
||||||
|
.steamId(rs.getObject("steam_id", Long.class))
|
||||||
|
.enabled(rs.getBoolean("enabled"))
|
||||||
|
.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;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<User> findByEmail(String email) {
|
public Optional<User> findByEmail(String email) {
|
||||||
var criteriaBuilder = entityManager.getCriteriaBuilder();
|
return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst();
|
||||||
var userEntityCriteriaQuery = criteriaBuilder.createQuery(MinimalUserEntity.class);
|
|
||||||
var userEntity = userEntityCriteriaQuery.from(MinimalUserEntity.class);
|
|
||||||
|
|
||||||
// Build the query: SELECT u FROM UserEntity u WHERE u.email = :email
|
|
||||||
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("email"), email));
|
|
||||||
|
|
||||||
TypedQuery<MinimalUserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
|
|
||||||
|
|
||||||
return typedQuery.getResultStream()
|
|
||||||
.findFirst()
|
|
||||||
.map(EntityMapper::mapToModel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<User> findById(long userId) {
|
public Optional<User> findById(long userId) {
|
||||||
return Optional.ofNullable(entityManager.find(MinimalUserEntity.class, userId)).map(EntityMapper::mapToModel);
|
return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<String> lockPasswordHash(long userId) {
|
||||||
|
return jdbc.query("SELECT password FROM users WHERE id = ? FOR SHARE", (rs, row) -> rs.getString("password"),
|
||||||
|
userId).stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateLoginDate(long userId) {
|
public void updateLoginDate(long userId) {
|
||||||
var userEntity = entityManager.find(MinimalUserEntity.class, userId);
|
jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId);
|
||||||
|
|
||||||
if (userEntity != null) {
|
|
||||||
userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC()));
|
|
||||||
entityManager.flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,11 @@ public interface RefreshTokenRepository {
|
|||||||
*/
|
*/
|
||||||
void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole);
|
void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without a lock: the caller locks the user first, as a password change does, so the two cannot deadlock.
|
||||||
|
*/
|
||||||
|
Optional<Long> findUserIdByHash(String tokenHash);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks the row until the transaction ends, so two refreshes with the same token run one after the other.
|
* Locks the row until the transaction ends, so two refreshes with the same token run one after the other.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
|
|||||||
entityManager.persist(entity);
|
entityManager.persist(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Long> findUserIdByHash(String tokenHash) {
|
||||||
|
return entityManager.createQuery("SELECT t.userId FROM RefreshTokenEntity t WHERE t.tokenHash = :hash", Long.class)
|
||||||
|
.setParameter("hash", tokenHash)
|
||||||
|
.getResultList()
|
||||||
|
.stream()
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<RefreshToken> findByHashForUpdate(String tokenHash) {
|
public Optional<RefreshToken> findByHashForUpdate(String tokenHash) {
|
||||||
return entityManager.createQuery(
|
return entityManager.createQuery(
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package auth_service.modules.login.persistence.entities;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.*;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a user entity mapped to the 'users' table in PostgreSQL.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "users")
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class MinimalUserEntity {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(unique = true, nullable = false)
|
|
||||||
private String email;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String password;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
@Column(name = "last_login_date")
|
|
||||||
private LocalDateTime lastLoginDate;
|
|
||||||
|
|
||||||
@Column(name = "steam_id")
|
|
||||||
private Long steamId;
|
|
||||||
|
|
||||||
@Column(name = "deleted_at", insertable = false, updatable = false)
|
|
||||||
private Instant deletedAt;
|
|
||||||
|
|
||||||
// Set by SQL only (the first admin).
|
|
||||||
@Column(name = "role", insertable = false, updatable = false)
|
|
||||||
private String role;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package auth_service.modules.login.persistence.mapper;
|
package auth_service.modules.login.persistence.mapper;
|
||||||
|
|
||||||
import auth_service.modules.login.model.RefreshToken;
|
import auth_service.modules.login.model.RefreshToken;
|
||||||
import auth_service.modules.login.model.User;
|
|
||||||
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
|
||||||
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
|
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
|
|
||||||
@@ -11,22 +9,6 @@ public abstract class EntityMapper {
|
|||||||
private EntityMapper() {
|
private EntityMapper() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static User mapToModel(MinimalUserEntity entity) {
|
|
||||||
User model = User.builder().build();
|
|
||||||
|
|
||||||
if (entity != null) {
|
|
||||||
model.setId(entity.getId());
|
|
||||||
model.setEmail(entity.getEmail());
|
|
||||||
model.setPassword(entity.getPassword());
|
|
||||||
model.setSteamId(entity.getSteamId());
|
|
||||||
model.setEnabled(entity.isEnabled());
|
|
||||||
model.setDeleted(entity.getDeletedAt() != null);
|
|
||||||
model.setRole(Role.parse(entity.getRole()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RefreshToken mapToModel(RefreshTokenEntity entity) {
|
public static RefreshToken mapToModel(RefreshTokenEntity entity) {
|
||||||
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
|
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
|
||||||
entity.getRotatedAt(), entity.getRevokedAt(),
|
entity.getRotatedAt(), entity.getRevokedAt(),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on every request with a token, so the answer is cached.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Public
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ActiveUserService implements ActiveUserCheck {
|
||||||
|
|
||||||
|
private final MinimalUserRepository minimalUserRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Cacheable(value = "activeUsers", key = "#userId")
|
||||||
|
public UserTokenState state(long userId) {
|
||||||
|
return minimalUserRepository.findById(userId).filter(User::canLogin)
|
||||||
|
.map(user -> new UserTokenState(true, user.getRoleChangedAt()))
|
||||||
|
.orElse(UserTokenState.INACTIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package auth_service.modules.login.service;
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
|
import auth_service.shared.account.EmailAddress;
|
||||||
import auth_service.shared.security.AttemptLimiter;
|
import auth_service.shared.security.AttemptLimiter;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts failed logins per email and per client IP in memory. A key is blocked after too many
|
* Counts failed logins per email and per client IP in memory. A key is blocked after too many
|
||||||
@@ -37,7 +37,7 @@ public class LoginAttemptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String emailKey(String email) {
|
private static String emailKey(String email) {
|
||||||
return "email:" + (email == null ? "" : email.trim().toLowerCase(Locale.ROOT));
|
return "email:" + (email == null ? "" : EmailAddress.normalize(email));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String ipKey(String clientIp) {
|
private static String ipKey(String clientIp) {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package auth_service.modules.login.service;
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
import auth_service.shared.security.Public;
|
import auth_service.shared.security.Public;
|
||||||
import auth_service.shared.security.TokenGenerator;
|
|
||||||
import auth_service.modules.login.exception.AccountBlockedException;
|
import auth_service.modules.login.exception.AccountBlockedException;
|
||||||
|
import auth_service.modules.login.exception.AccountPendingException;
|
||||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.model.Session;
|
import auth_service.modules.login.model.Session;
|
||||||
import auth_service.modules.login.model.User;
|
import auth_service.modules.login.model.User;
|
||||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.Cache;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -21,33 +22,32 @@ import java.util.UUID;
|
|||||||
public class LoginService {
|
public class LoginService {
|
||||||
private final MinimalUserRepository minimalUserRepository;
|
private final MinimalUserRepository minimalUserRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final TokenGenerator tokenGenerator;
|
|
||||||
private final LoginAttemptService loginAttemptService;
|
private final LoginAttemptService loginAttemptService;
|
||||||
private final RefreshTokenService refreshTokenService;
|
private final RefreshTokenService refreshTokenService;
|
||||||
|
private final CacheManager cacheManager;
|
||||||
|
|
||||||
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
|
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
|
||||||
private final String dummyHash;
|
private final String dummyHash;
|
||||||
|
|
||||||
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
|
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
|
||||||
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService,
|
LoginAttemptService loginAttemptService, RefreshTokenService refreshTokenService,
|
||||||
RefreshTokenService refreshTokenService) {
|
CacheManager cacheManager) {
|
||||||
this.minimalUserRepository = minimalUserRepository;
|
this.minimalUserRepository = minimalUserRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.tokenGenerator = tokenGenerator;
|
|
||||||
this.loginAttemptService = loginAttemptService;
|
this.loginAttemptService = loginAttemptService;
|
||||||
this.refreshTokenService = refreshTokenService;
|
this.refreshTokenService = refreshTokenService;
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
|
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
|
||||||
* same InvalidCredentialsException; a blocked account says so, but only after the right password.
|
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right
|
||||||
|
* password. An unconfirmed user has no password and fails like a wrong one.
|
||||||
*
|
*
|
||||||
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
|
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
|
||||||
* so its login is revoked; else it would stay valid unseen for 30 days.
|
* so its login is revoked; else it would stay valid unseen for 30 days.
|
||||||
*/
|
*/
|
||||||
// /api/users/me caches the user by email; a role set by SQL must show after the next login.
|
|
||||||
@CacheEvict(value = "usersByEmail", key = "#userToLogin.email")
|
|
||||||
public Session login(User userToLogin, String clientIp, String previousRefreshToken) {
|
public Session login(User userToLogin, String clientIp, String previousRefreshToken) {
|
||||||
String email = userToLogin.getEmail();
|
String email = userToLogin.getEmail();
|
||||||
loginAttemptService.acquire(email, clientIp);
|
loginAttemptService.acquire(email, clientIp);
|
||||||
@@ -59,9 +59,13 @@ public class LoginService {
|
|||||||
if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) {
|
if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) {
|
||||||
throw new InvalidCredentialsException("Invalid email or password");
|
throw new InvalidCredentialsException("Invalid email or password");
|
||||||
}
|
}
|
||||||
// Only after the right password, so a wrong one never tells that the account exists and is blocked.
|
// Only after the right password, so a wrong one never tells that the account exists and is pending or blocked.
|
||||||
|
// The right password is no failed attempt; else the user gets 429 and stays locked after the accept or unblock.
|
||||||
|
if (foundUser.get().isPending()) {
|
||||||
|
loginAttemptService.recordSuccess(email, clientIp);
|
||||||
|
throw new AccountPendingException();
|
||||||
|
}
|
||||||
if (!foundUser.get().isEnabled()) {
|
if (!foundUser.get().isEnabled()) {
|
||||||
// The right password is no failed attempt; else the user gets 429 and stays locked after an unblock.
|
|
||||||
loginAttemptService.recordSuccess(email, clientIp);
|
loginAttemptService.recordSuccess(email, clientIp);
|
||||||
throw new AccountBlockedException();
|
throw new AccountBlockedException();
|
||||||
}
|
}
|
||||||
@@ -69,9 +73,12 @@ public class LoginService {
|
|||||||
User user = foundUser.get();
|
User user = foundUser.get();
|
||||||
loginAttemptService.recordSuccess(email, clientIp);
|
loginAttemptService.recordSuccess(email, clientIp);
|
||||||
minimalUserRepository.updateLoginDate(user.getId());
|
minimalUserRepository.updateLoginDate(user.getId());
|
||||||
|
// /api/users/me caches the user; a role set by SQL must show after the next login.
|
||||||
|
Cache users = cacheManager.getCache("users");
|
||||||
|
if (users != null) {
|
||||||
|
users.evict(user.getId());
|
||||||
|
}
|
||||||
refreshTokenService.revoke(previousRefreshToken);
|
refreshTokenService.revoke(previousRefreshToken);
|
||||||
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId(),
|
return refreshTokenService.startSession(user.getId(), hash);
|
||||||
user.getRole());
|
|
||||||
return new Session(accessToken, refreshTokenService.startFamily(user.getId()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.login.service;
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.model.RefreshToken;
|
import auth_service.modules.login.model.RefreshToken;
|
||||||
import auth_service.modules.login.model.Session;
|
import auth_service.modules.login.model.Session;
|
||||||
import auth_service.modules.login.model.User;
|
import auth_service.modules.login.model.User;
|
||||||
@@ -46,9 +47,22 @@ public class RefreshTokenService {
|
|||||||
private final SessionProperties sessionProperties;
|
private final SessionProperties sessionProperties;
|
||||||
private final AdminAuditLog adminAuditLog;
|
private final AdminAuditLog adminAuditLog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param passwordHash the hash the login checked. A password change in between ended every login, so this one
|
||||||
|
* must not start.
|
||||||
|
* @throws InvalidCredentialsException when the password is not the one checked
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public String startFamily(long userId) {
|
public Session startSession(long userId, String passwordHash) {
|
||||||
return issue(userId, UUID.randomUUID(), Instant.now(), null);
|
if (!userRepository.lockPasswordHash(userId).map(current -> current.equals(passwordHash)).orElse(false)) {
|
||||||
|
throw new InvalidCredentialsException("Invalid email or password");
|
||||||
|
}
|
||||||
|
// Read after the lock, so a role change or block that committed during the login is seen.
|
||||||
|
User current = userRepository.findById(userId).filter(User::canLogin)
|
||||||
|
.orElseThrow(() -> new InvalidCredentialsException("Invalid email or password"));
|
||||||
|
String accessToken = tokenGenerator.generateToken(String.valueOf(userId), current.getSteamId(),
|
||||||
|
current.getRole(), current.getRoleChangedAt());
|
||||||
|
return new Session(accessToken, issue(userId, UUID.randomUUID(), Instant.now(), null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +93,12 @@ public class RefreshTokenService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
|
// The user lock comes before the token lock, as in a password change. A change that commits after this
|
||||||
|
// point sees the new token when it revokes; one that committed before has revoked this token already.
|
||||||
|
Optional<Long> owner = refreshTokenRepository.findUserIdByHash(hash(rawToken));
|
||||||
|
if (owner.isEmpty() || userRepository.lockPasswordHash(owner.get()).isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
Optional<RefreshToken> found = refreshTokenRepository.findByHashForUpdate(hash(rawToken));
|
Optional<RefreshToken> found = refreshTokenRepository.findByHashForUpdate(hash(rawToken));
|
||||||
if (found.isEmpty()) {
|
if (found.isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
@@ -110,8 +130,8 @@ public class RefreshTokenService {
|
|||||||
if (switching && stored != Role.USER) {
|
if (switching && stored != Role.USER) {
|
||||||
adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name());
|
adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name());
|
||||||
}
|
}
|
||||||
String accessToken = tokenGenerator.generateToken(
|
String accessToken = tokenGenerator.generateToken(String.valueOf(current.getId()), current.getSteamId(), active,
|
||||||
String.valueOf(current.getId()), current.getEmail(), current.getSteamId(), active);
|
current.getRoleChangedAt());
|
||||||
return Optional.of(new Session(accessToken, next));
|
return Optional.of(new Session(accessToken, next));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package auth_service.modules.mail.api;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.api.dto.request.BroadcastRequest;
|
||||||
|
import auth_service.modules.mail.api.dto.response.BroadcastListResponse;
|
||||||
|
import auth_service.modules.mail.api.dto.response.BroadcastResponse;
|
||||||
|
import auth_service.modules.mail.api.dto.response.RecipientCountResponse;
|
||||||
|
import auth_service.modules.mail.api.dto.response.SentBroadcastResponse;
|
||||||
|
import auth_service.modules.mail.model.Broadcast;
|
||||||
|
import auth_service.modules.mail.model.BroadcastText;
|
||||||
|
import auth_service.modules.mail.service.BroadcastService;
|
||||||
|
import auth_service.shared.security.UserPrincipal;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/broadcasts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminBroadcastController {
|
||||||
|
|
||||||
|
private final BroadcastService broadcastService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<BroadcastListResponse> findAll() {
|
||||||
|
return ResponseEntity.ok(new BroadcastListResponse(broadcastService.findAll().stream()
|
||||||
|
.map(AdminBroadcastController::toApi).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recipient-count")
|
||||||
|
public ResponseEntity<RecipientCountResponse> recipientCount(@RequestParam(required = false) List<Long> userIds) {
|
||||||
|
return ResponseEntity.ok(new RecipientCountResponse(broadcastService.countRecipients(userIds)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<SentBroadcastResponse> send(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody BroadcastRequest request) {
|
||||||
|
int queued = broadcastService.send(principal.getIdAsLong(), toText(request), request.userIds());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(new SentBroadcastResponse(queued));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/test")
|
||||||
|
public ResponseEntity<Void> sendTest(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody BroadcastRequest request) {
|
||||||
|
broadcastService.sendTest(principal.getIdAsLong(), toText(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BroadcastText toText(BroadcastRequest request) {
|
||||||
|
return new BroadcastText(request.subjectEn().strip(), request.subjectDe().strip(),
|
||||||
|
request.bodyEn().strip(), request.bodyDe().strip());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BroadcastResponse toApi(Broadcast broadcast) {
|
||||||
|
BroadcastText text = broadcast.text();
|
||||||
|
return new BroadcastResponse(broadcast.id(), text.subjectEn(), text.subjectDe(), text.bodyEn(), text.bodyDe(),
|
||||||
|
broadcast.createdBy(), broadcast.createdAt(), broadcast.recipientCount(), broadcast.sentCount(),
|
||||||
|
broadcast.failedCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package auth_service.modules.mail.api;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.exception.NoRecipientsException;
|
||||||
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice(assignableTypes = AdminBroadcastController.class)
|
||||||
|
@Order(value = 1)
|
||||||
|
public class BroadcastExceptionController {
|
||||||
|
|
||||||
|
@ExceptionHandler(NoRecipientsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> conflict(NoRecipientsException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new ErrorResponse("NO_RECIPIENTS", ex.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text, never HTML.
|
||||||
|
*
|
||||||
|
* @param userIds null = every active user; the test mail ignores it
|
||||||
|
*/
|
||||||
|
public record BroadcastRequest(@NotBlank @Size(max = 150) String subjectEn, @NotBlank @Size(max = 150) String subjectDe,
|
||||||
|
@NotBlank @Size(max = 5000) String bodyEn, @NotBlank @Size(max = 5000) String bodyDe,
|
||||||
|
List<Long> userIds) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record BroadcastListResponse(List<BroadcastResponse> results) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record BroadcastResponse(long id, String subjectEn, String subjectDe, String bodyEn, String bodyDe,
|
||||||
|
long createdBy, Instant createdAt, int recipientCount, int sentCount,
|
||||||
|
int failedCount) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
public record RecipientCountResponse(long count) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.api.dto.response;
|
||||||
|
|
||||||
|
public record SentBroadcastResponse(int recipientCount) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.mail.exception;
|
||||||
|
|
||||||
|
public class NoRecipientsException extends RuntimeException {
|
||||||
|
|
||||||
|
public NoRecipientsException() {
|
||||||
|
super("No active user among the recipients");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record Broadcast(long id, BroadcastText text, long createdBy, Instant createdAt, int recipientCount,
|
||||||
|
int sentCount, int failedCount) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
public record BroadcastText(String subjectEn, String subjectDe, String bodyEn, String bodyDe) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
public record Mail(String subject, String body) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text only: names and addresses go in as they are, and no mail client renders them as HTML.
|
||||||
|
*/
|
||||||
|
public enum MailTemplate {
|
||||||
|
|
||||||
|
REGISTRATION_REQUESTED(
|
||||||
|
"New registration on ToBePlayed waits", """
|
||||||
|
A new registration waits for your decision.
|
||||||
|
|
||||||
|
Name: %s
|
||||||
|
Email: %s
|
||||||
|
|
||||||
|
Accept or deny it here:
|
||||||
|
%s
|
||||||
|
""",
|
||||||
|
"Neue Registrierung bei ToBePlayed wartet", """
|
||||||
|
Eine neue Registrierung wartet auf deine Entscheidung.
|
||||||
|
|
||||||
|
Name: %s
|
||||||
|
E-Mail: %s
|
||||||
|
|
||||||
|
Hier nimmst du sie an oder lehnst sie ab:
|
||||||
|
%s
|
||||||
|
"""),
|
||||||
|
|
||||||
|
// %1$s greeting, %2$s link.
|
||||||
|
CONFIRM_EMAIL(
|
||||||
|
"Confirm your email address for ToBePlayed", """
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
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
|
||||||
|
""",
|
||||||
|
"Bestätige deine E-Mail-Adresse für ToBePlayed", """
|
||||||
|
Hallo %s,
|
||||||
|
|
||||||
|
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
|
||||||
|
"""),
|
||||||
|
|
||||||
|
ACCOUNT_ACTIVE(
|
||||||
|
"Your ToBePlayed account is active", """
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
your account is active. You can log in now:
|
||||||
|
%s
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
""",
|
||||||
|
"Dein ToBePlayed-Konto ist aktiv", """
|
||||||
|
Hallo %s,
|
||||||
|
|
||||||
|
dein Konto ist aktiv. Du kannst dich jetzt anmelden:
|
||||||
|
%s
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
"""),
|
||||||
|
|
||||||
|
REGISTRATION_DENIED(
|
||||||
|
"Your ToBePlayed registration was denied", """
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
an administrator has denied your registration, so no account was made.
|
||||||
|
Your data will be deleted in 30 days.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
""",
|
||||||
|
"Deine ToBePlayed-Registrierung wurde abgelehnt", """
|
||||||
|
Hallo %s,
|
||||||
|
|
||||||
|
ein Administrator hat deine Registrierung abgelehnt. Es gibt kein Konto für dich.
|
||||||
|
Deine Daten werden in 30 Tagen gelöscht.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
"""),
|
||||||
|
|
||||||
|
ACCOUNT_BLOCKED(
|
||||||
|
"Your ToBePlayed account is blocked", """
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
an administrator has blocked your account, so you cannot log in.
|
||||||
|
If you think this is a mistake, answer this mail.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
""",
|
||||||
|
"Dein ToBePlayed-Konto ist gesperrt", """
|
||||||
|
Hallo %s,
|
||||||
|
|
||||||
|
ein Administrator hat dein Konto gesperrt. Du kannst dich nicht mehr anmelden.
|
||||||
|
Wenn das ein Fehler ist, antworte auf diese Mail.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
"""),
|
||||||
|
|
||||||
|
ACCOUNT_DELETED(
|
||||||
|
"Your ToBePlayed account will be deleted", """
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
your account is marked for deletion, and you cannot log in any more.
|
||||||
|
All your data will be deleted from %s.
|
||||||
|
|
||||||
|
ToBePlayed
|
||||||
|
""",
|
||||||
|
"Dein ToBePlayed-Konto wird gelöscht", """
|
||||||
|
Hallo %s,
|
||||||
|
|
||||||
|
dein Konto ist zum Löschen markiert. Du kannst dich nicht mehr anmelden.
|
||||||
|
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");
|
||||||
|
private static final DateTimeFormatter DATE_EN = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.ENGLISH);
|
||||||
|
|
||||||
|
private final Mail en;
|
||||||
|
private final Mail de;
|
||||||
|
|
||||||
|
MailTemplate(String subjectEn, String bodyEn, String subjectDe, String bodyDe) {
|
||||||
|
this.en = new Mail(subjectEn, bodyEn);
|
||||||
|
this.de = new Mail(subjectDe, bodyDe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* German for "de", English for any other or no language.
|
||||||
|
*/
|
||||||
|
public Mail render(String language, Object... args) {
|
||||||
|
Mail mail = isGerman(language) ? de : en;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isGerman(String language) {
|
||||||
|
return "de".equals(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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,11 @@
|
|||||||
|
package auth_service.modules.mail.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param language '' or null when the user never chose one
|
||||||
|
*/
|
||||||
|
public record Recipient(String email, String displayName, String language) {
|
||||||
|
|
||||||
|
public String greeting() {
|
||||||
|
return displayName == null || displayName.isBlank() ? email : displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package auth_service.modules.mail.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.model.Mail;
|
||||||
|
import auth_service.modules.mail.model.MailTemplate;
|
||||||
|
import auth_service.modules.mail.model.OutboxRow;
|
||||||
|
import auth_service.modules.mail.model.Recipient;
|
||||||
|
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.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class MailOutboxRepository {
|
||||||
|
|
||||||
|
private static final int MAX_ERROR_LENGTH = 500;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public MailOutboxRepository(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Recipient> findRecipient(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT u.email, u.display_name, s.language
|
||||||
|
FROM users u LEFT JOIN user_settings s ON s.user_id = u.id
|
||||||
|
WHERE u.id = ?""",
|
||||||
|
(rs, row) -> new Recipient(rs.getString("email"), rs.getString("display_name"), rs.getString("language")),
|
||||||
|
userId).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert(MailTemplate kind, long userId, String toAddress, Mail mail) {
|
||||||
|
jdbc.update("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
kind.name(), userId, toAddress, mail.subject(), mail.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OutboxRow> findDue(Instant now, int limit) {
|
||||||
|
return jdbc.query("""
|
||||||
|
SELECT id, kind, to_address, subject, body, attempts, broadcast_id FROM mail_outbox
|
||||||
|
WHERE status = 'PENDING' AND next_attempt_at <= ?
|
||||||
|
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.getObject("broadcast_id", Long.class)),
|
||||||
|
Timestamp.from(now), limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markSent(long id, Instant now) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markRetry(long id, int attempts, Instant nextAttemptAt, String error) {
|
||||||
|
jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?",
|
||||||
|
attempts, Timestamp.from(nextAttemptAt), shorten(error), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markFailed(long id, int attempts, String error) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String shorten(String error) {
|
||||||
|
return error == null || error.length() <= MAX_ERROR_LENGTH ? error : error.substring(0, MAX_ERROR_LENGTH);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package auth_service.modules.mail.service;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.model.OutboxRow;
|
||||||
|
import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.MailException;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the rows of {@code mail_outbox}. Mail is off without {@code SPRING_MAIL_HOST}: the rows wait and go out once
|
||||||
|
* a host is set.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class MailOutboxService {
|
||||||
|
|
||||||
|
// About 4 hours of tries with the backoff below.
|
||||||
|
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.daily-limit}") int dailyLimit) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.dailyLimit = dailyLimit;
|
||||||
|
this.from = from;
|
||||||
|
if (host.isBlank()) {
|
||||||
|
this.sender = null;
|
||||||
|
log.info("Mail is off: no SPRING_MAIL_HOST");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least once: when the server took a mail but markSent fails, the next run sends it again.
|
||||||
|
// ponytail: no row lock, safe because one Auth instance runs and Spring starts a cron run only after the last
|
||||||
|
// one ended; use SELECT ... FOR UPDATE SKIP LOCKED before running a second instance.
|
||||||
|
@Scheduled(cron = "${app.mail.cron}")
|
||||||
|
public void sendDue() {
|
||||||
|
if (sender == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Instant now = Instant.now();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(OutboxRow row, Instant now) {
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(from);
|
||||||
|
message.setTo(row.toAddress());
|
||||||
|
message.setSubject(row.subject());
|
||||||
|
message.setText(row.body());
|
||||||
|
try {
|
||||||
|
sender.send(message);
|
||||||
|
repository.markSent(row.id(), now);
|
||||||
|
} catch (MailException e) {
|
||||||
|
int attempts = row.attempts() + 1;
|
||||||
|
// The SMTP answer can name the address, so only last_error gets it.
|
||||||
|
if (attempts >= MAX_ATTEMPTS) {
|
||||||
|
repository.markFailed(row.id(), attempts, e.getMessage());
|
||||||
|
log.warn("Mail {} ({}) given up after {} tries: {}", row.id(), row.kind(), attempts,
|
||||||
|
e.getClass().getSimpleName());
|
||||||
|
} else {
|
||||||
|
repository.markRetry(row.id(), attempts, now.plus(backoff(attempts)), e.getMessage());
|
||||||
|
log.info("Mail {} ({}) failed, try {} of {}: {}", row.id(), row.kind(), attempts, MAX_ATTEMPTS,
|
||||||
|
e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1, 2, 4 ... 32 minutes, then the hour; attempts stay below MAX_ATTEMPTS, so the shift cannot overflow.
|
||||||
|
static Duration backoff(int attempts) {
|
||||||
|
return Duration.ofMinutes(Math.min(1L << (attempts - 1), 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package auth_service.modules.mail.service;
|
||||||
|
|
||||||
|
import auth_service.modules.mail.model.MailTemplate;
|
||||||
|
import auth_service.modules.mail.model.Recipient;
|
||||||
|
import auth_service.modules.mail.repo.MailOutboxRepository;
|
||||||
|
import auth_service.shared.mail.AccountMailer;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders each mail when the event happens and stores the finished text, so a later change of the name or the
|
||||||
|
* language does not change a mail that waits.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MailService implements AccountMailer {
|
||||||
|
|
||||||
|
// The admin reads German.
|
||||||
|
private static final String ADMIN_LANGUAGE = "de";
|
||||||
|
|
||||||
|
private final MailOutboxRepository repository;
|
||||||
|
private final String adminTo;
|
||||||
|
private final String frontendUrl;
|
||||||
|
|
||||||
|
public MailService(MailOutboxRepository repository, @Value("${app.mail.admin-to}") String adminTo,
|
||||||
|
@Value("${app.frontend-url}") String frontendUrl) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.adminTo = adminTo;
|
||||||
|
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 -> {
|
||||||
|
// Blank only while mail is off: with a host, MailOutboxService stops the start without it.
|
||||||
|
if (!adminTo.isBlank()) {
|
||||||
|
repository.insert(MailTemplate.REGISTRATION_REQUESTED, userId, adminTo, MailTemplate.REGISTRATION_REQUESTED
|
||||||
|
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(),
|
||||||
|
frontendUrl + "/admin/users"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accountAccepted(long userId) {
|
||||||
|
repository.findRecipient(userId).ifPresent(user ->
|
||||||
|
insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registrationDenied(long userId) {
|
||||||
|
repository.findRecipient(userId).ifPresent(user ->
|
||||||
|
insertForUser(MailTemplate.REGISTRATION_DENIED, userId, user, user.greeting()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accountBlocked(long userId) {
|
||||||
|
repository.findRecipient(userId).ifPresent(user ->
|
||||||
|
insertForUser(MailTemplate.ACCOUNT_BLOCKED, userId, user, user.greeting()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accountDeleted(long userId, LocalDate deletedFrom) {
|
||||||
|
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.ACCOUNT_DELETED, userId, user,
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package auth_service.modules.news.api;
|
||||||
|
|
||||||
|
import auth_service.modules.news.api.dto.request.NewsRequest;
|
||||||
|
import auth_service.modules.news.api.dto.response.AdminNewsListResponse;
|
||||||
|
import auth_service.modules.news.api.dto.response.AdminNewsResponse;
|
||||||
|
import auth_service.modules.news.api.dto.response.CreatedNewsResponse;
|
||||||
|
import auth_service.modules.news.model.AdminNews;
|
||||||
|
import auth_service.modules.news.model.News;
|
||||||
|
import auth_service.modules.news.model.NewsText;
|
||||||
|
import auth_service.modules.news.service.AdminNewsService;
|
||||||
|
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.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;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/news")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminNewsController {
|
||||||
|
|
||||||
|
private final AdminNewsService adminNewsService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<AdminNewsListResponse> findAll() {
|
||||||
|
return ResponseEntity.ok(new AdminNewsListResponse(adminNewsService.findAll().stream()
|
||||||
|
.map(AdminNewsController::toApi).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<CreatedNewsResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@Valid @RequestBody NewsRequest request) {
|
||||||
|
long id = adminNewsService.create(principal.getIdAsLong(), toText(request));
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedNewsResponse(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{newsId}")
|
||||||
|
public ResponseEntity<Void> update(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long newsId,
|
||||||
|
@Valid @RequestBody NewsRequest request) {
|
||||||
|
adminNewsService.update(principal.getIdAsLong(), newsId, toText(request));
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{newsId}")
|
||||||
|
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long newsId) {
|
||||||
|
adminNewsService.delete(principal.getIdAsLong(), newsId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NewsText toText(NewsRequest request) {
|
||||||
|
return new NewsText(request.titleEn(), request.titleDe(), request.textEn(), request.textDe());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminNewsResponse toApi(AdminNews admin) {
|
||||||
|
News news = admin.news();
|
||||||
|
return new AdminNewsResponse(news.id(), news.titleEn(), news.titleDe(), news.textEn(), news.textDe(),
|
||||||
|
news.publishedAt(), admin.createdBy(), admin.createdAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package auth_service.modules.news.api;
|
||||||
|
|
||||||
|
import auth_service.modules.news.api.dto.response.NewsListResponse;
|
||||||
|
import auth_service.modules.news.api.dto.response.NewsResponse;
|
||||||
|
import auth_service.modules.news.model.News;
|
||||||
|
import auth_service.modules.news.model.UserNews;
|
||||||
|
import auth_service.modules.news.service.NewsService;
|
||||||
|
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.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/news")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NewsController {
|
||||||
|
|
||||||
|
private final NewsService newsService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<NewsListResponse> findAll(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
return ResponseEntity.ok(new NewsListResponse(newsService.findAllFor(principal.getIdAsLong()).stream()
|
||||||
|
.map(NewsController::toApi).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/seen")
|
||||||
|
public ResponseEntity<Void> markSeen(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
newsService.markSeen(principal.getIdAsLong());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NewsResponse toApi(UserNews userNews) {
|
||||||
|
News news = userNews.news();
|
||||||
|
return new NewsResponse(news.id(), news.titleEn(), news.titleDe(), news.textEn(), news.textDe(),
|
||||||
|
news.publishedAt(), userNews.seen());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package auth_service.modules.news.api;
|
||||||
|
|
||||||
|
import auth_service.modules.news.exception.NewsNotFoundException;
|
||||||
|
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 = {NewsController.class, AdminNewsController.class})
|
||||||
|
@Order(value = 1)
|
||||||
|
public class NewsExceptionController {
|
||||||
|
|
||||||
|
@ExceptionHandler(NewsNotFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(NewsNotFoundException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new ErrorResponse("NEWS_NOT_FOUND", ex.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package auth_service.modules.news.api.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text, never read as HTML.
|
||||||
|
*/
|
||||||
|
public record NewsRequest(@NotBlank @Size(max = 100) String titleEn, @Size(max = 100) String titleDe,
|
||||||
|
@NotBlank @Size(max = 2000) String textEn, @Size(max = 2000) String textDe) {
|
||||||
|
}
|
||||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user