Public Access
Compare commits
50
Commits
930e08e6dd
...
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 | ||
|
|
315e22c220 | ||
|
|
dd4fcd1546 | ||
|
|
f86f3c2e63 | ||
|
|
32d4f40eec | ||
|
|
5341e0b870 | ||
|
|
ff26760d40 | ||
|
|
2ba0d99148 | ||
|
|
7dc2fb613e | ||
|
|
a1763addce | ||
|
|
4ec47f1b2c | ||
|
|
4b2acef381 | ||
|
|
e3433799f1 |
No files matched your search
@@ -52,6 +52,7 @@ jobs:
|
||||
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -69,6 +70,9 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
BUILD_TIME=${{ steps.prep.outputs.build_time }}
|
||||
tags: |
|
||||
git.andreas-dahm.eu/lobo_corp/tbp-auth-service:${{ steps.prep.outputs.branch }}
|
||||
git.andreas-dahm.eu/lobo_corp/tbp-auth-service:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
|
||||
|
||||
@@ -25,11 +25,20 @@ FROM eclipse-temurin:25-jre-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Passed by the CI workflow; shown by GET /api/version
|
||||
ARG GIT_SHA=dev
|
||||
ARG BUILD_TIME=
|
||||
ENV APP_BUILD_COMMIT=$GIT_SHA APP_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
# Expose the port your Spring Boot app runs on
|
||||
EXPOSE 8080
|
||||
|
||||
# Copy the compiled jar file from the builder stage
|
||||
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
|
||||
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>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- Rest Caching -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
@@ -82,11 +86,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- testing -->
|
||||
<dependency>
|
||||
|
||||
@@ -1,62 +1,198 @@
|
||||
# To Be Played – Auth Service
|
||||
|
||||
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the
|
||||
public profile, and it signs the JWTs that the Core service trusts.
|
||||
Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||
by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It handles registration, login, user
|
||||
settings and the public profile, and it signs the JWTs that the Core service trusts.
|
||||
|
||||
To Be Played is split into three repositories:
|
||||
To Be Played is split into four repositories:
|
||||
|
||||
| Repository | Role |
|
||||
|---|---|
|
||||
| **ToBePlayed-Auth** (this one) | Users, login, settings, 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-Frontend | React single-page app |
|
||||
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||
|
||||
## Features
|
||||
|
||||
- Registration with an invitation code, login with email and password (BCrypt).
|
||||
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP,
|
||||
block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
|
||||
- Registration by email confirmation (a single-use link, valid 24 hours, on whose page the owner chooses the
|
||||
password; unconfirmed requests are deleted after 7 days) and then admin approval, login with email and password (BCrypt). Emails are stored trimmed and in
|
||||
lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit).
|
||||
- **Password reset**: "forgot password" mails a link (`/reset-password#<token>`, single use, valid 1 hour, only the
|
||||
SHA-256 stored, a new mail replaces the old link). Only an ACTIVE user gets a working link; every other address gets
|
||||
the same `204` and no mail. A reset ends all logins; "change password" (current password + new one) ends all logins
|
||||
but the current one. A PLATFORM_ADMIN can send the same mail to an active user. Both send a "password changed" mail.
|
||||
- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations
|
||||
per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a
|
||||
wrong password, and every failed login gets the same error.
|
||||
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
|
||||
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
|
||||
public key as a JWK, so Core finds the right key after a rotation.
|
||||
public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends
|
||||
every access token signed with the old key at once. The frontend renews them with the refresh cookie.
|
||||
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
|
||||
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
|
||||
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
|
||||
token that comes back revokes all tokens of that login. Logout revokes them too.
|
||||
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
||||
frontend origin. A `Bearer` header still works for API clients and tests.
|
||||
- User settings: display name (unique, case-insensitive), Steam ID, language, dark mode.
|
||||
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
|
||||
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||||
- User settings: display name (unique, case-insensitive), language, dark mode, and the chart form per stat group
|
||||
(`stat_charts`, V31): bars, pie, donut, line or list. `StatCharts` holds the allowed forms per group.
|
||||
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
||||
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
||||
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
||||
foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but
|
||||
unverified: a verified link from another account takes them away. Unlinking is possible.
|
||||
- Appearance settings synced across devices: accent color, corner style, wide layout, reduce animations, background and
|
||||
dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||||
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
|
||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The ticket is not a login:
|
||||
both services reject every token with a `typ` claim on normal endpoints.
|
||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
||||
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
|
||||
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
|
||||
`style`. The ticket also names the user's `role`, so Core can show the free looks of an admin. The ticket's
|
||||
claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_` tile is shown),
|
||||
`profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
||||
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
||||
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
||||
A login's `sub` is the user id; the email is in no token. 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
|
||||
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
|
||||
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
|
||||
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
|
||||
refresh. Core checks the same claim.
|
||||
**Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`).
|
||||
The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in
|
||||
`admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access
|
||||
token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache
|
||||
TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by
|
||||
SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every
|
||||
write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR
|
||||
UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account.
|
||||
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
|
||||
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
|
||||
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
|
||||
a denied call answers `401` without a login, else `403`. `@Public` skips only the role check: login, refresh,
|
||||
"view as" and the Steam callback prove the caller themselves. The URL rules in `SecurityConfig` stay as the outer
|
||||
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
|
||||
service method calling another service's method that needs more.
|
||||
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
|
||||
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
|
||||
answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as
|
||||
"Delete my account"). No action on the own account
|
||||
or on another admin. Every action and every "view as" is a row in `admin_audit`.
|
||||
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
|
||||
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
|
||||
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
|
||||
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
|
||||
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
|
||||
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
|
||||
A hint can target groups, combined with AND (none = all users): without Steam, role (this role and higher, by the
|
||||
active role of the token), new users (`users.registration_date` less than N days ago), empty library (Auth only
|
||||
sends a flag; the Frontend checks it with Core). The text may use `**bold**` and `[text](url)`; the Frontend
|
||||
renders it, never as HTML. Four automatic hints (`NO_STEAM`, `EMPTY_LIBRARY`, `PILE_GREW`, `LINK_QUEUE`) are on
|
||||
by default; a PLATFORM_ADMIN turns each on or off (`auto_hint`, audited). `GET /api/hints` lists the keys that are
|
||||
on and not hidden for the user; the Frontend checks the conditions. X hides one for 7 days, "Do not show again"
|
||||
for good (`auto_hint_hidden`).
|
||||
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
|
||||
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
|
||||
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
|
||||
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
|
||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||||
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
||||
undo needs a new Steam link. A nightly job (04:15, Europe/Berlin) removes
|
||||
users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a
|
||||
**service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Auth |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/registration/new` | public |
|
||||
| `POST` | `/api/registration/confirm` (`{token, password}`; `204`; `410 CONFIRMATION_INVALID`) | public |
|
||||
| `POST` | `/api/registration/resend` (`{email}`; always `204`) | public |
|
||||
| `POST` | `/api/auth/login` | public, sets both cookies |
|
||||
| `POST` | `/api/auth/password-reset` (`{email}`; always `204`; `429 TOO_MANY_ATTEMPTS`: 5 per IP and 15 min) | public |
|
||||
| `POST` | `/api/auth/password-reset/confirm` (`{token, password}`; `204`; `410 RESET_INVALID`; `429`) | public |
|
||||
| `POST` | `/api/auth/password` (`{currentPassword, newPassword}`; `204`; `403 INVALID_PASSWORD`; `429`; under `/api/auth` for the refresh cookie, which keeps the current login) | access token |
|
||||
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
|
||||
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
|
||||
| `GET` | `/api/users/me` | access token |
|
||||
| `PUT` | `/api/users/settings` | access token, sets a new access cookie |
|
||||
| `PUT` | `/api/users/settings` | access token |
|
||||
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
|
||||
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
|
||||
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
|
||||
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/users/{id}/password-reset` (`204`; `409 NOT_ACTIVE`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/hints` (`{results}`: running hints for this user not closed for good, newest first, with `emptyLibraryOnly`; `automatic`: keys of the automatic hints that are on and not hidden) | access token |
|
||||
| `PUT` | `/api/hints/auto/{key}/snooze` (hide 7 days; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||||
| `PUT` | `/api/hints/auto/{key}/done` (hide for good; `204`; `404 HINT_NOT_FOUND`) | access token |
|
||||
| `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token |
|
||||
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount` and the `target*` fields, newest first; `automatic`: `[{key, enabled}]`) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}` and optional `targetNoSteam`, `targetRole` (this role and higher), `targetNewDays` (1-365), `targetEmptyLibrary`; `201 {id}`, `400 INVALID_HINT` for the time range; `400 VALIDATION_FAILED` for a bad `targetNewDays` or `targetRole`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/hints/auto/{key}` (`{enabled}`; `204`; `404 HINT_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
|
||||
| `PUT` | `/api/news/seen` (`204`) | access token |
|
||||
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `409 LAST_ADMIN` (the only active admin), `429`) | access token |
|
||||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
||||
| `PUT` | `/api/users/settings/appearance` | access token |
|
||||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
||||
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
|
||||
| `PUT` | `/api/users/settings/welcome-seen` (`204`; stamps `user_settings.welcome_seen_at`, sent as `settings.welcomeSeenAt` in `/me`, null until then) | access token |
|
||||
| `GET` | `/api/admin/broadcasts` (`{results}` with the counts, newest first) | PLATFORM_ADMIN |
|
||||
| `GET` | `/api/admin/broadcasts/recipient-count?userIds=` (`{count}`; no `userIds` = all) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/broadcasts` (`{subjectEn, subjectDe, bodyEn, bodyDe, userIds}`; `201 {recipientCount}`; `409 NO_RECIPIENTS`) | PLATFORM_ADMIN |
|
||||
| `POST` | `/api/admin/broadcasts/test` (same body; `204`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
||||
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||
| `PUT` | `/api/users/settings/stat-chart` (`{group, chart}`; `chart` null = back to the default; `204`; `400 VALIDATION_FAILED` for an unknown group or form) | access token |
|
||||
| `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token |
|
||||
| `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token |
|
||||
| `GET` | `/api/stats/public` | public |
|
||||
|
||||
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
|
||||
| `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 |
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
|
||||
- PostgreSQL 17, Flyway migrations
|
||||
- JJWT with Ed25519 keys
|
||||
- Spring Boot Actuator + Micrometer (Prometheus)
|
||||
- Spring Boot Actuator (`/actuator/health` only)
|
||||
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
||||
- CI: Forgejo Actions, multi-stage Docker image
|
||||
- CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -66,37 +202,282 @@ layers:
|
||||
| Layer | Package | Rule |
|
||||
|---|---|---|
|
||||
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
|
||||
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. No dependency on API or persistence. |
|
||||
| Persistence | `persistence` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. |
|
||||
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
|
||||
| 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.
|
||||
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
||||
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
|
||||
through `CoreAccountService`.
|
||||
`admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and
|
||||
the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never
|
||||
use each other.
|
||||
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
|
||||
`shared/audit/AdminAuditLog`.
|
||||
`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
|
||||
violation fails the build. Test base classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`,
|
||||
`AbstractIntegrationTest`) live in `auth_service.shared.test`; all integration tests share one PostgreSQL container.
|
||||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||||
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
|
||||
`auth_service.shared.test`. 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
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser (Frontend)
|
||||
participant A as Auth
|
||||
participant C as Core
|
||||
B->>A: POST /api/auth/login
|
||||
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
||||
B->>C: API call with tbp_access
|
||||
C->>A: GET /.well-known/jwks.json (unknown kid only)
|
||||
A-->>C: Current public key (Ed25519, one JWK)
|
||||
C-->>B: 200, or 401 when the access token expired
|
||||
B->>A: POST /api/auth/refresh with tbp_refresh
|
||||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||||
B->>C: Same API call again
|
||||
Note over B,A: View as (admin only)
|
||||
B->>A: POST /api/auth/view-as {role} with tbp_refresh
|
||||
A-->>B: New tbp_access (role claim) and tbp_refresh, active role stored for this login
|
||||
```
|
||||
|
||||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||||
can read them.
|
||||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core
|
||||
loads it again only for an unknown `kid`.
|
||||
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
||||
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
||||
|
||||
### Steam link flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser (Frontend)
|
||||
participant A as Auth
|
||||
participant S as Steam
|
||||
B->>A: POST /api/users/steam-link (logged in)
|
||||
A-->>B: Steam login URL, Set-Cookie tbp_steam_link (state, 10 min)
|
||||
B->>S: Sign in on steamcommunity.com
|
||||
S-->>B: Redirect to /api/steam-link/callback?state=...&openid.*
|
||||
B->>A: GET callback with tbp_steam_link
|
||||
A->>S: check_authentication (openid.* sent back)
|
||||
S-->>A: is_valid:true
|
||||
A-->>B: Redirect to /settings?steam=linked
|
||||
B->>A: POST /api/auth/refresh (new steamId claim for Core)
|
||||
```
|
||||
|
||||
`SteamLinkService` checks the state against the binding cookie before it asks Steam anything, and uses each state
|
||||
once. `SteamOpenIdService` checks mode, endpoint, `return_to` and the signed fields, then asks Steam.
|
||||
`UserService.linkVerifiedSteamId` stores the ID and takes it from an account that only typed it in.
|
||||
|
||||
### Delete account flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser (Frontend)
|
||||
participant A as Auth
|
||||
participant C as Core
|
||||
B->>A: DELETE /api/users/me {password}
|
||||
A->>A: users.deleted_at = now, revoke refresh tokens, mail row (one transaction)
|
||||
A->>C: POST /internal/accounts/deleted (service token)
|
||||
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
|
||||
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->>A: DELETE users (only when Core answered and no dry run)
|
||||
A->>C: POST /internal/accounts/prune (all user ids)
|
||||
```
|
||||
|
||||
A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30
|
||||
days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
||||
|
||||
### 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
|
||||
|
||||
Needs Java 25 and Docker.
|
||||
### Local development without keys (profile `local`)
|
||||
|
||||
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
|
||||
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
|
||||
other, then start them in this order (Core also starts without Auth and finds it later):
|
||||
|
||||
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
|
||||
(IDE: active profile `local`). Port 8080.
|
||||
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
|
||||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
|
||||
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||||
|
||||
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
|
||||
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
|
||||
restart), log in again. Never use `local` on a server: the dev user's password is public. The
|
||||
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until
|
||||
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`)
|
||||
|
||||
Needs Java 25, Docker and your own keys.
|
||||
|
||||
```bash
|
||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
```
|
||||
|
||||
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
||||
service listens on `http://localhost:8080`. Health: `/actuator/health`, metrics: `/actuator/prometheus`.
|
||||
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
||||
|
||||
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
||||
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
||||
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
|
||||
local port.
|
||||
Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the
|
||||
environment. `local` must not be combined with `prod`.
|
||||
|
||||
Only `tbp_access` gets the domain from `APP_AUTH_COOKIE_DOMAIN` (blank = host-only; production sets the parent
|
||||
domain, so Core gets it too). `tbp_refresh` (`Path=/api/auth`) and `tbp_steam_link` are always host-only. The cookies
|
||||
are `Secure` (`app.auth.cookie-secure`, default `true`). Chrome and Firefox accept `Secure` cookies on
|
||||
`http://localhost`; Safari drops them, so the `local` profile sets `cookie-secure: false`. With your own keys on Safari
|
||||
set `APP_AUTH_COOKIE_SECURE=false`. Open the frontend on `localhost`, not `127.0.0.1`.
|
||||
|
||||
Configuration comes from environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
|
||||
| `JWT_KID` | Key id written into the token header |
|
||||
| `AUTH_INVITATION_CODE` | Code required for registration |
|
||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||
| `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank |
|
||||
| `APP_AUTH_COOKIE_DOMAIN` | Domain of `tbp_access`, blank = host-only |
|
||||
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the nightly cleanup deletes nothing (it still sends the account states to Core); the 7-day purge of unconfirmed registrations runs anyway |
|
||||
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
|
||||
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
||||
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||||
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
|
||||
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
|
||||
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
|
||||
| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host |
|
||||
| `APP_MAIL_DAILY_LIMIT` | Mails per rolling 24 h, all kinds together (default 100, Hostinger's free mailbox; 1000 Business Starter, 3000 Business Standard / Premium). Broadcast rows keep 10 free |
|
||||
|
||||
Generate a keypair:
|
||||
|
||||
@@ -106,6 +487,13 @@ openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
|
||||
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
|
||||
```
|
||||
|
||||
### Request timing log
|
||||
|
||||
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
|
||||
duration, e.g. `GET /api/users/me 200 12 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
|
||||
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
|
||||
that leaves the filter chain is logged as `500`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
@@ -116,7 +504,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Account deletion and data export.
|
||||
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||||
|
||||
## 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;
|
||||
|
||||
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;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Global configuration to enable Spring Boot caching (backed by Caffeine).
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
@Profile("!dev")
|
||||
@Slf4j
|
||||
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
|
||||
public CacheManager cacheManager() {
|
||||
log.info("########### Productive cache active ###########");
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
|
||||
cacheManager.setCaffeine(caffeineCacheBuilder());
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats");
|
||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||
.maximumSize(500)
|
||||
.expireAfterWrite(DEFAULT_TTL)
|
||||
.recordStats());
|
||||
cacheManager.registerCustomCache("activeUsers", Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterWrite(ACTIVE_USERS_TTL)
|
||||
.recordStats()
|
||||
.build());
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package auth_service.configuration;
|
||||
|
||||
import org.springframework.boot.EnvironmentPostProcessor;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Only in the local profile: a new Ed25519 key pair and kid at each start, so no signing key is in the repo. A new
|
||||
* kid lets Core's JWKS reload find the new key by itself.
|
||||
*/
|
||||
public class LocalKeysEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
|
||||
|
||||
static final String LOCAL_PROFILE = "local";
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
@Override
|
||||
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
|
||||
if (!environment.matchesProfiles(LOCAL_PROFILE)) {
|
||||
return;
|
||||
}
|
||||
// local also seeds an admin with a public password and opens CORS to localhost.
|
||||
if (environment.matchesProfiles(PROD_PROFILE)) {
|
||||
throw new IllegalStateException("The local profile must never run together with prod");
|
||||
}
|
||||
KeyPair keys = generateKeys();
|
||||
Base64.Encoder base64 = Base64.getEncoder();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("localKeys", Map.of(
|
||||
"security.jwt.private-key", base64.encodeToString(keys.getPrivate().getEncoded()),
|
||||
"security.jwt.public-key", base64.encodeToString(keys.getPublic().getEncoded()),
|
||||
"security.jwt.kid", LOCAL_PROFILE + "-" + UUID.randomUUID())));
|
||||
}
|
||||
|
||||
// After the config files, which decide the active profiles.
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE;
|
||||
}
|
||||
|
||||
private static KeyPair generateKeys() {
|
||||
try {
|
||||
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public class ObservabilityConfig {
|
||||
|
||||
/**
|
||||
* Registers the TimedAspect bean to enable @Timed annotations.
|
||||
* * @param registry The Micrometer MeterRegistry
|
||||
* @param registry The Micrometer MeterRegistry
|
||||
*
|
||||
* @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,8 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.security.ActiveUserCheck;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -9,19 +11,22 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
|
||||
* verifies. A stale cookie next to a valid header must not block the request.
|
||||
* verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user, and a token
|
||||
* issued before the last role change of its user, count as not logged in.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -30,6 +35,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private static final String BEARER = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final ActiveUserCheck activeUserCheck;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@@ -42,12 +48,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
.flatMap(Optional::stream)
|
||||
.map(jwtService::parseLoginToken)
|
||||
.flatMap(Optional::stream)
|
||||
.filter(each -> activeUserCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
|
||||
.findFirst();
|
||||
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
principal.get(),
|
||||
null,
|
||||
Collections.emptyList()
|
||||
authorities(principal.get().role())
|
||||
);
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
@@ -56,6 +63,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active role and every role below it, so hasRole("AUDITOR") also lets an admin in.
|
||||
*/
|
||||
private static List<SimpleGrantedAuthority> authorities(Role role) {
|
||||
return Arrays.stream(Role.values()).filter(role::atLeast)
|
||||
.map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList();
|
||||
}
|
||||
|
||||
private static Optional<String> bearer(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
|
||||
|
||||
@@ -2,6 +2,8 @@ package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.security.ProfileTicketIssuer;
|
||||
import auth_service.shared.security.PublicKeyPublisher;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.ServiceTokenIssuer;
|
||||
import auth_service.shared.security.SessionProperties;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
@@ -17,6 +19,7 @@ import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -28,13 +31,17 @@ import java.util.Optional;
|
||||
* Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
|
||||
*/
|
||||
@Service
|
||||
public class JwtService implements TokenGenerator, ProfileTicketIssuer, 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;
|
||||
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
||||
private static final String TYPE_CLAIM = "typ";
|
||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
||||
private static final String PROFILE_TICKET_SUBJECT = "public-profile";
|
||||
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
|
||||
private static final String ED25519 = "Ed25519";
|
||||
private static final String ROLE_CLAIM = "role";
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private final SessionProperties sessionProperties;
|
||||
@@ -59,13 +66,17 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateToken(String userId, String email, Long steamId) {
|
||||
public String generateToken(String userId, Long steamId, Role role, Instant notBefore) {
|
||||
long now = System.currentTimeMillis();
|
||||
long issuedAt = notBefore == null ? now : Math.max(now, notBefore.toEpochMilli());
|
||||
var builder = Jwts.builder()
|
||||
.header().keyId(jwtConfig.getKid()).and()
|
||||
.subject(email)
|
||||
.claim("userId", userId)
|
||||
.issuedAt(new Date(System.currentTimeMillis()))
|
||||
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||
.issuer(ISSUER)
|
||||
.audience().add(LOGIN_AUDIENCE).and()
|
||||
.subject(userId)
|
||||
.claim(ROLE_CLAIM, role.name())
|
||||
.issuedAt(new Date(issuedAt))
|
||||
.expiration(new Date(now + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||
|
||||
if (steamId != null) {
|
||||
builder.claim("steamId", steamId);
|
||||
@@ -77,18 +88,35 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateProfileTicket(long userId) {
|
||||
public String generateProfileTicket(long userId, Role role, List<String> profileStats, List<String> sections) {
|
||||
return Jwts.builder()
|
||||
.header().keyId(jwtConfig.getKid()).and()
|
||||
.subject(PROFILE_TICKET_TYPE)
|
||||
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
|
||||
.issuer(ISSUER)
|
||||
.audience().add(PROFILE_TICKET_AUDIENCE).and()
|
||||
.subject(PROFILE_TICKET_SUBJECT)
|
||||
.claim("profileUserId", String.valueOf(userId))
|
||||
.claim(ROLE_CLAIM, role.name())
|
||||
.claim("profileStats", profileStats)
|
||||
.claim("sections", sections)
|
||||
.issuedAt(new Date(System.currentTimeMillis()))
|
||||
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
|
||||
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateServiceToken() {
|
||||
return Jwts.builder()
|
||||
.header().keyId(jwtConfig.getKid()).and()
|
||||
.issuer(ISSUER)
|
||||
.audience().add(SERVICE_AUDIENCE).and()
|
||||
.subject("tbp-auth")
|
||||
.issuedAt(new Date(System.currentTimeMillis()))
|
||||
.expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS))
|
||||
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, ?>> publicJwks() {
|
||||
return List.of(Jwks.builder()
|
||||
@@ -106,16 +134,26 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
|
||||
public Optional<UserPrincipal> parseLoginToken(String token) {
|
||||
try {
|
||||
Claims claims = parser.parseSignedClaims(token).getPayload();
|
||||
String userId = claims.get("userId", String.class);
|
||||
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
|
||||
if (!isKind(claims, LOGIN_AUDIENCE)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class)));
|
||||
String userId = claims.getSubject();
|
||||
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 _) {
|
||||
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) {
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import org.springframework.aop.Advisor;
|
||||
import org.springframework.aop.Pointcut;
|
||||
import org.springframework.aop.support.Pointcuts;
|
||||
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Role;
|
||||
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
|
||||
/**
|
||||
* Runs {@link RoleAuthorizationManager} before every method with a role marker, or of a class with one. A method
|
||||
* that is only {@code @Public} needs no check.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableMethodSecurity(prePostEnabled = false)
|
||||
public class MethodSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||
static Advisor roleAuthorization() {
|
||||
Pointcut marked = Pointcuts.union(new AnnotationMatchingPointcut(RequiresRole.class, true),
|
||||
new AnnotationMatchingPointcut(null, RequiresRole.class, true));
|
||||
return new AuthorizationManagerBeforeMethodInterceptor(marked, new RoleAuthorizationManager());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.security.Public;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import org.aopalliance.intercept.MethodInvocation;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.AuthorizationManager;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import java.lang.reflect.AnnotatedElement;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
class RoleAuthorizationManager implements AuthorizationManager<MethodInvocation> {
|
||||
|
||||
private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true);
|
||||
private static final AuthorizationDecision DENIED = new AuthorizationDecision(false);
|
||||
|
||||
@Override
|
||||
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
||||
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
|
||||
: invocation.getMethod().getDeclaringClass();
|
||||
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
|
||||
AnnotatedElement marked = isMarked(method) ? method : targetClass;
|
||||
if (AnnotatedElementUtils.hasAnnotation(marked, Public.class)) {
|
||||
return GRANTED;
|
||||
}
|
||||
RequiresRole required = AnnotatedElementUtils.findMergedAnnotation(marked, RequiresRole.class);
|
||||
if (required == null) {
|
||||
return DENIED;
|
||||
}
|
||||
return hasAuthority(authentication.get(), "ROLE_" + required.value().name()) ? GRANTED : DENIED;
|
||||
}
|
||||
|
||||
private static boolean isMarked(Method method) {
|
||||
return AnnotatedElementUtils.hasAnnotation(method, RequiresRole.class)
|
||||
|| AnnotatedElementUtils.hasAnnotation(method, Public.class);
|
||||
}
|
||||
|
||||
private static boolean hasAuthority(Authentication authentication, String authority) {
|
||||
return authentication != null && authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority).anyMatch(authority::equals);
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,10 @@ public class SecurityConfig {
|
||||
"/api/registration/**", // Allow new user registration
|
||||
"/api/stats/public", // Public landing-page counters (cached, no PII)
|
||||
"/api/public/**", // Opt-in public profiles, see PublicProfileController
|
||||
"/api/steam-link/callback", // Steam's redirect back; bound to the browser by a cookie, see SteamLinkService
|
||||
"/actuator/health", // Docker health check; details are off
|
||||
"/.well-known/jwks.json", // Public verify keys for Core
|
||||
"/api/version", // Build of the running image, shown in the settings
|
||||
"/error" // Default Spring Boot error page routing
|
||||
};
|
||||
|
||||
@@ -95,7 +97,11 @@ public class SecurityConfig {
|
||||
// 401, not Spring's default 403: the frontend renews the access cookie only on a 401.
|
||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Under /api/auth only for the refresh cookie; it changes the password of the logged-in user.
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/password").authenticated()
|
||||
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
||||
// The active role: an admin who views as USER gets 403 here.
|
||||
.requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN")
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package auth_service.modules.account.api;
|
||||
|
||||
import auth_service.modules.account.api.dto.request.ChangePasswordRequest;
|
||||
import auth_service.modules.account.api.dto.request.DeleteAccountRequest;
|
||||
import auth_service.modules.account.api.dto.request.ForgotPasswordRequest;
|
||||
import auth_service.modules.account.api.dto.request.ResetPasswordRequest;
|
||||
import auth_service.modules.account.service.AccountService;
|
||||
import auth_service.modules.account.service.PasswordResetService;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.api.ClientIp;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.CookieValue;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final AuthCookies authCookies;
|
||||
|
||||
@DeleteMapping(value = "/api/users/me")
|
||||
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody DeleteAccountRequest request) {
|
||||
accountService.deleteAccount(principal.getIdAsLong(), request.password());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
|
||||
return ResponseEntity.noContent().headers(headers).build();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/api/auth/password-reset", consumes = "application/json")
|
||||
public ResponseEntity<Void> requestReset(@Valid @RequestBody ForgotPasswordRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
passwordResetService.requestReset(request.email(), ClientIp.of(httpRequest));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/api/auth/password-reset/confirm", consumes = "application/json")
|
||||
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
passwordResetService.resetPassword(request.token(), request.password(), ClientIp.of(httpRequest));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// Under /api/auth only because the refresh cookie has that path; it tells which login stays.
|
||||
@PostMapping(value = "/api/auth/password", consumes = "application/json")
|
||||
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody ChangePasswordRequest request,
|
||||
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false)
|
||||
String refreshToken) {
|
||||
accountService.changePassword(principal.getIdAsLong(), request.currentPassword(), request.newPassword(),
|
||||
refreshToken);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package auth_service.modules.account.api;
|
||||
|
||||
import auth_service.modules.account.exception.InvalidPasswordException;
|
||||
import auth_service.modules.account.exception.LastAdminException;
|
||||
import auth_service.modules.account.exception.PasswordResetInvalidException;
|
||||
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
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 = AccountController.class)
|
||||
@Order(value = 1)
|
||||
public class AccountExceptionController {
|
||||
|
||||
// 403, not 401: the frontend answers a 401 with a refresh and sends the request again.
|
||||
@ExceptionHandler(InvalidPasswordException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(InvalidPasswordException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(PasswordResetInvalidException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(PasswordResetInvalidException ex) {
|
||||
return ResponseEntity.status(HttpStatus.GONE).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("RESET_INVALID", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(LastAdminException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(LastAdminException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("LAST_ADMIN", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(TooManyAttemptsException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(TooManyAttemptsException ex) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("TOO_MANY_ATTEMPTS", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -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,6 @@
|
||||
package auth_service.modules.account.api.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record DeleteAccountRequest(@NotBlank String password) {
|
||||
}
|
||||
@@ -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 InvalidPasswordException extends RuntimeException {
|
||||
public InvalidPasswordException() {
|
||||
super("The password is wrong");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth_service.modules.account.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Core's answer to a purge or prune.
|
||||
*
|
||||
* @param users the users Core touched, or would touch in a dry run
|
||||
* @param rows rows per table Core deleted, or would delete
|
||||
* @param refused true when Core found more orphans than it allows and did nothing
|
||||
*/
|
||||
public record CoreCleanupResult(boolean dryRun, List<Long> users, Map<String, Integer> rows, boolean refused) {
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package auth_service.modules.account.repo;
|
||||
|
||||
import auth_service.modules.account.model.CoreAccountState;
|
||||
import auth_service.shared.account.ActiveAdmins;
|
||||
import auth_service.shared.account.ActiveUser;
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import auth_service.shared.account.ListedUser;
|
||||
import auth_service.shared.account.ProfileSections;
|
||||
import auth_service.shared.account.PublicUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Array;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The deleted flag on users. Plain SQL, because the user and login modules own the entities.
|
||||
*/
|
||||
@Repository
|
||||
public class AccountRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final NamedParameterJdbcTemplate namedJdbc;
|
||||
|
||||
public AccountRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) {
|
||||
this.jdbc = jdbc;
|
||||
this.namedJdbc = namedJdbc;
|
||||
}
|
||||
|
||||
public Optional<String> findPasswordHashOfActive(long userId) {
|
||||
return jdbc.queryForList("SELECT password FROM users WHERE id = ? AND deleted_at IS NULL", String.class, userId)
|
||||
.stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the flag, frees the Steam ID for a new account and ends every login of the user. A user flagged before
|
||||
* keeps the first time.
|
||||
*
|
||||
* @return 1 when the user was flagged now
|
||||
*/
|
||||
@Transactional
|
||||
public int flagDeleted(long userId, Instant now) {
|
||||
Timestamp at = Timestamp.from(now);
|
||||
int flagged = jdbc.update("""
|
||||
UPDATE users SET deleted_at = ?, steam_id = NULL, steam_id_verified = FALSE
|
||||
WHERE id = ? AND deleted_at IS NULL""", at, userId);
|
||||
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", at, userId);
|
||||
return flagged;
|
||||
}
|
||||
|
||||
public Optional<Long> findActiveIdByEmail(String email) {
|
||||
return jdbc.queryForList("SELECT u.id FROM users u WHERE u.email = ? AND " + ActiveUser.of("u"), Long.class,
|
||||
EmailAddress.normalize(email)).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an older reset link of the user.
|
||||
*
|
||||
* @return false when the user is not active
|
||||
*/
|
||||
public boolean storePasswordReset(long userId, String tokenHash, Instant expiresAt) {
|
||||
return jdbc.update("""
|
||||
UPDATE users u SET password_reset_hash = ?, password_reset_expires_at = ?
|
||||
WHERE u.id = ? AND\s""" + ActiveUser.of("u"), tokenHash, Timestamp.from(expiresAt), userId) == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password and uses up the link in one statement, so a link works once.
|
||||
*
|
||||
* @return the id of the user, empty when the link is unknown, used, expired or the user is not active
|
||||
*/
|
||||
public Optional<Long> resetPassword(String tokenHash, String passwordHash, Instant now) {
|
||||
return jdbc.queryForList("""
|
||||
UPDATE users u SET password = ?, password_reset_hash = NULL, password_reset_expires_at = NULL
|
||||
WHERE u.password_reset_hash = ? AND u.password_reset_expires_at > ? AND\s""" + ActiveUser.of("u")
|
||||
+ " RETURNING u.id", Long.class, passwordHash, tokenHash, Timestamp.from(now)).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Also ends an open reset link.
|
||||
*/
|
||||
public void updatePassword(long userId, String passwordHash) {
|
||||
jdbc.update("""
|
||||
UPDATE users SET password = ?, password_reset_hash = NULL, password_reset_expires_at = NULL
|
||||
WHERE id = ?""", passwordHash, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends every login of the user but the one of keepTokenHash; null ends all. IS DISTINCT FROM, because for an
|
||||
* unknown hash the subquery is NULL and a plain comparison would revoke nothing.
|
||||
*/
|
||||
public int revokeSessions(long userId, String keepTokenHash, Instant now) {
|
||||
return jdbc.update("""
|
||||
UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL
|
||||
AND family_id IS DISTINCT FROM (SELECT family_id FROM refresh_tokens WHERE token_hash = ? AND user_id = ?)""",
|
||||
Timestamp.from(now), userId, keepTokenHash, userId);
|
||||
}
|
||||
|
||||
public List<Long> findFlaggedIds() {
|
||||
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
|
||||
}
|
||||
|
||||
public List<Long> findFlaggedBefore(Instant cutoff) {
|
||||
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at < ? ORDER BY id", Long.class,
|
||||
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.
|
||||
*/
|
||||
public List<Long> findAllIds() {
|
||||
return jdbc.queryForList("SELECT id FROM users ORDER BY id", Long.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings and refresh tokens go by their ON DELETE CASCADE.
|
||||
*/
|
||||
public int deleteUsers(Collection<Long> userIds) {
|
||||
if (userIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package auth_service.modules.account.service;
|
||||
|
||||
import auth_service.modules.account.model.CoreCleanupResult;
|
||||
import auth_service.modules.account.repo.AccountRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Nightly, after Core's sync: tells Core who is flagged and every user's Steam ID and block, removes users flagged
|
||||
* longer than the retention (Core first, then Auth), and lets Core remove rows of users Auth does not know.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AccountCleanupService {
|
||||
|
||||
static final String ZONE = "Europe/Berlin";
|
||||
static final Duration UNCONFIRMED_RETENTION = Duration.ofDays(7);
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final CoreAccountService coreAccountService;
|
||||
private final boolean dryRun;
|
||||
private final Duration retention;
|
||||
|
||||
public AccountCleanupService(AccountRepository accountRepository, CoreAccountService coreAccountService,
|
||||
@Value("${app.account-cleanup.dry-run}") boolean dryRun,
|
||||
@Value("${app.account-cleanup.retention}") Duration retention) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.coreAccountService = coreAccountService;
|
||||
this.dryRun = dryRun;
|
||||
this.retention = retention;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE)
|
||||
public void runNightly() {
|
||||
purgeUnconfirmed();
|
||||
try {
|
||||
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
sendStates();
|
||||
purgeDue();
|
||||
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() {
|
||||
List<Long> due = accountRepository.findFlaggedBefore(Instant.now().minus(retention));
|
||||
if (due.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CoreCleanupResult result = coreAccountService.purge(due, dryRun);
|
||||
log.info("Account cleanup: Core {} users {}, rows {}", result.dryRun() ? "would delete" : "deleted",
|
||||
result.users(), result.rows());
|
||||
if (dryRun || result.dryRun()) {
|
||||
log.info("Account cleanup (dry run): would delete users {} in Auth", due);
|
||||
return;
|
||||
}
|
||||
// Core skips a user without a tombstone; that user stays flagged and goes next night.
|
||||
List<Long> purged = due.stream().filter(result.users()::contains).toList();
|
||||
accountRepository.deleteUsers(purged);
|
||||
log.info("Account cleanup: deleted users {} in Auth", purged);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Account cleanup: purge of users {} failed, tried again next night: {}", due, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneOrphans() {
|
||||
try {
|
||||
CoreCleanupResult result = coreAccountService.prune(accountRepository.findAllIds(), dryRun);
|
||||
if (result.refused()) {
|
||||
log.warn("Account cleanup: Core refused the prune, too many orphans: {}", result.users());
|
||||
} else if (!result.users().isEmpty()) {
|
||||
log.info("Account cleanup: Core {} orphans {}, rows {}", result.dryRun() ? "would delete" : "deleted",
|
||||
result.users(), result.rows());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Account cleanup: prune failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package auth_service.modules.account.service;
|
||||
|
||||
import auth_service.modules.account.exception.InvalidPasswordException;
|
||||
import auth_service.modules.account.exception.LastAdminException;
|
||||
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||
import auth_service.modules.account.repo.AccountRepository;
|
||||
import auth_service.shared.account.AccountDeleter;
|
||||
import auth_service.shared.account.AccountStatePublisher;
|
||||
import auth_service.shared.mail.AccountMailer;
|
||||
import auth_service.shared.security.AttemptLimiter;
|
||||
import auth_service.shared.security.OpaqueToken;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionOperations;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiresRole(Role.USER)
|
||||
@Slf4j
|
||||
public class AccountService implements AccountDeleter {
|
||||
|
||||
private static final Duration LOCK = Duration.ofMinutes(15);
|
||||
private static final int MAX_WRONG_PASSWORDS = 5;
|
||||
private static final ZoneId CLEANUP_ZONE = ZoneId.of(AccountCleanupService.ZONE);
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final CoreAccountService coreAccountService;
|
||||
private final AccountMailer accountMailer;
|
||||
private final AccountStatePublisher accountStatePublisher;
|
||||
private final TransactionOperations transactions;
|
||||
private final Duration retention;
|
||||
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
|
||||
|
||||
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
|
||||
CoreAccountService coreAccountService, AccountMailer accountMailer,
|
||||
AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
|
||||
@Value("${app.account-cleanup.retention}") Duration retention) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.coreAccountService = coreAccountService;
|
||||
this.accountMailer = accountMailer;
|
||||
this.accountStatePublisher = accountStatePublisher;
|
||||
this.transactions = transactions;
|
||||
this.retention = retention;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
|
||||
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||
public void deleteAccount(long userId, String password) {
|
||||
String key = acquireAttempt(userId);
|
||||
Optional<String> hash = accountRepository.findPasswordHashOfActive(userId);
|
||||
if (hash.isEmpty()) {
|
||||
// Already flagged, e.g. a second click: the answer is the same.
|
||||
attemptLimiter.release(key);
|
||||
return;
|
||||
}
|
||||
checkPassword(key, password, hash.get());
|
||||
flagAndTellCore(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the login of refreshToken (may be null: then all logins end) and ends every other one.
|
||||
*/
|
||||
public void changePassword(long userId, String currentPassword, String newPassword, String refreshToken) {
|
||||
String key = acquireAttempt(userId);
|
||||
checkPassword(key, currentPassword,
|
||||
accountRepository.findPasswordHashOfActive(userId).orElseThrow(InvalidPasswordException::new));
|
||||
String hash = passwordEncoder.encode(newPassword);
|
||||
transactions.executeWithoutResult(status -> {
|
||||
accountRepository.updatePassword(userId, hash);
|
||||
accountRepository.revokeSessions(userId, refreshToken == null ? null : OpaqueToken.hash(refreshToken),
|
||||
Instant.now());
|
||||
accountMailer.passwordChanged(userId);
|
||||
});
|
||||
log.info("Password of user {} changed", userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@RequiresRole(Role.PLATFORM_ADMIN)
|
||||
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
|
||||
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||
public boolean deleteByAdmin(long 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) {
|
||||
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;
|
||||
}
|
||||
log.info("Account of user {} flagged as deleted", userId);
|
||||
try {
|
||||
coreAccountService.markDeleted(List.of(userId));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Core did not take the delete of user {}, the state push tries again: {}", userId,
|
||||
e.getMessage());
|
||||
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
|
||||
accountStatePublisher.publish(userId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package auth_service.modules.account.service;
|
||||
|
||||
import auth_service.modules.account.model.CoreAccountState;
|
||||
import auth_service.modules.account.model.CoreCleanupResult;
|
||||
import auth_service.shared.security.ServiceTokenIssuer;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Core's /internal/accounts endpoints, called with a service token. Every error throws.
|
||||
*/
|
||||
@Service
|
||||
public class CoreAccountService {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final ServiceTokenIssuer serviceTokenIssuer;
|
||||
|
||||
@Autowired
|
||||
public CoreAccountService(@Value("${app.core.internal-url}") String coreUrl, ServiceTokenIssuer serviceTokenIssuer) {
|
||||
this(RestClient.builder().baseUrl(coreUrl).requestFactory(requestFactory()).build(), serviceTokenIssuer);
|
||||
}
|
||||
|
||||
CoreAccountService(RestClient restClient, ServiceTokenIssuer serviceTokenIssuer) {
|
||||
this.restClient = restClient;
|
||||
this.serviceTokenIssuer = serviceTokenIssuer;
|
||||
}
|
||||
|
||||
private static SimpleClientHttpRequestFactory requestFactory() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(3));
|
||||
factory.setReadTimeout(Duration.ofSeconds(10));
|
||||
return factory;
|
||||
}
|
||||
|
||||
public void markDeleted(List<Long> userIds) {
|
||||
restClient.post().uri("/internal/accounts/deleted").headers(this::authorize)
|
||||
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
|
||||
}
|
||||
|
||||
/**
|
||||
* After this, Core hides exactly these users.
|
||||
*/
|
||||
public void replaceDeleted(List<Long> userIds) {
|
||||
restClient.put().uri("/internal/accounts/deleted").headers(this::authorize)
|
||||
.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
|
||||
*/
|
||||
public CoreCleanupResult purge(List<Long> userIds, boolean dryRun) {
|
||||
return restClient.post().uri("/internal/accounts/purge").headers(this::authorize)
|
||||
.body(Map.of("userIds", userIds, "dryRun", dryRun)).retrieve().body(CoreCleanupResult.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param knownUserIds every user of Auth, also the flagged ones
|
||||
* @param dryRun see purge
|
||||
*/
|
||||
public CoreCleanupResult prune(List<Long> knownUserIds, boolean dryRun) {
|
||||
return restClient.post().uri("/internal/accounts/prune").headers(this::authorize)
|
||||
.body(Map.of("knownUserIds", knownUserIds, "dryRun", dryRun)).retrieve().body(CoreCleanupResult.class);
|
||||
}
|
||||
|
||||
private void authorize(HttpHeaders headers) {
|
||||
headers.setBearerAuth(serviceTokenIssuer.generateServiceToken());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth_service.modules.admin.api;
|
||||
|
||||
import auth_service.modules.admin.exception.AdminActionRefusedException;
|
||||
import auth_service.modules.admin.exception.TargetUserNotFoundException;
|
||||
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 = AdminUserController.class)
|
||||
@Order(value = 1)
|
||||
public class AdminExceptionController {
|
||||
|
||||
@ExceptionHandler(AdminActionRefusedException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(AdminActionRefusedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(TargetUserNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(TargetUserNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package auth_service.modules.admin.api;
|
||||
|
||||
import auth_service.modules.admin.api.dto.request.BlockRequest;
|
||||
import auth_service.modules.admin.api.dto.request.DecisionRequest;
|
||||
import auth_service.modules.admin.api.dto.request.RoleChangeRequest;
|
||||
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
|
||||
import auth_service.modules.admin.api.dto.response.AdminUserResponse;
|
||||
import auth_service.modules.admin.api.dto.response.PendingCountResponse;
|
||||
import auth_service.modules.admin.api.dto.response.RoleChangeListResponse;
|
||||
import auth_service.modules.admin.api.dto.response.RoleChangeResponse;
|
||||
import auth_service.modules.admin.model.AdminUser;
|
||||
import auth_service.modules.admin.model.AdminUserPage;
|
||||
import auth_service.modules.admin.model.RoleChange;
|
||||
import auth_service.modules.admin.service.AdminUserService;
|
||||
import auth_service.modules.admin.service.RoleChangeService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminUserService adminUserService;
|
||||
private final RoleChangeService roleChangeService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "") String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
AdminUserPage found = adminUserService.findUsers(query, status, page, size);
|
||||
return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(),
|
||||
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
||||
}
|
||||
|
||||
@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,
|
||||
@Valid @RequestBody BlockRequest request) {
|
||||
adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{userId}/role")
|
||||
public ResponseEntity<Void> changeRole(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
||||
@Valid @RequestBody RoleChangeRequest request) {
|
||||
roleChangeService.changeRole(principal.getIdAsLong(), userId, request.expectedRole(), request.role());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/role-changes")
|
||||
public ResponseEntity<RoleChangeListResponse> findRoleChanges(@PathVariable long userId) {
|
||||
return ResponseEntity.ok(new RoleChangeListResponse(roleChangeService.findChanges(userId).stream()
|
||||
.map(AdminUserController::toApi).toList()));
|
||||
}
|
||||
|
||||
@DeleteMapping(value = "/{userId}")
|
||||
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
|
||||
adminUserService.delete(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping(value = "/{userId}/unconfirmed")
|
||||
public ResponseEntity<Void> deleteUnconfirmed(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable long userId) {
|
||||
adminUserService.deleteUnconfirmed(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/password-reset")
|
||||
public ResponseEntity<Void> sendPasswordReset(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable long userId) {
|
||||
adminUserService.sendPasswordReset(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static AdminUserResponse toApi(AdminUser user) {
|
||||
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
|
||||
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
|
||||
user.status());
|
||||
}
|
||||
|
||||
private static RoleChangeResponse toApi(RoleChange change) {
|
||||
return new RoleChangeResponse(change.actorUserId(), change.actorEmail(), change.oldRole().name(),
|
||||
change.newRole().name(), change.changedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.admin.api.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record BlockRequest(@NotNull Boolean blocked) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.admin.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminUserPageResponse(List<AdminUserResponse> content, int page, int size, long totalElements,
|
||||
int totalPages) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.admin.api.dto.response;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate,
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package auth_service.modules.admin.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class AdminActionRefusedException extends RuntimeException {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
public AdminActionRefusedException(String code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.admin.exception;
|
||||
|
||||
public class TargetUserNotFoundException extends RuntimeException {
|
||||
public TargetUserNotFoundException(long userId) {
|
||||
super("No user with id " + userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
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
|
||||
*/
|
||||
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
|
||||
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt,
|
||||
String status) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The page shape of Core's library list, so the frontend pages work the same way.
|
||||
*/
|
||||
public record AdminUserPage(List<AdminUser> content, int page, int size, long totalElements, int totalPages) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param actorEmail null after the actor is purged
|
||||
*/
|
||||
public record RoleChange(long actorUserId, String actorEmail, Role oldRole, Role newRole, Instant changedAt) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param deleted flagged as deleted, or denied
|
||||
* @param roleChangedAt null when the role never changed on the admin page
|
||||
*/
|
||||
public record RoleTarget(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
public record TargetState(Role role, boolean deleted, boolean pending, boolean unconfirmed) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth_service.modules.admin.repo;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class AdminAuditRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AdminAuditRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public void insert(long actorUserId, String action, Long targetUserId, String detail) {
|
||||
jdbc.update("INSERT INTO admin_audit (actor_user_id, action, target_user_id, detail) VALUES (?, ?, ?, ?)",
|
||||
actorUserId, action, targetUserId, detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package auth_service.modules.admin.repo;
|
||||
|
||||
import auth_service.modules.admin.model.AdminUser;
|
||||
import auth_service.modules.admin.model.TargetState;
|
||||
import auth_service.shared.security.Role;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Plain SQL on users, because the user and login modules own the entities.
|
||||
*/
|
||||
@Repository
|
||||
public class AdminUserRepository {
|
||||
|
||||
// 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 HAS_STATUS = "(? = '' OR status = ?)";
|
||||
|
||||
private static final RowMapper<AdminUser> ROW = (rs, row) -> new AdminUser(
|
||||
rs.getLong("id"),
|
||||
rs.getString("email"),
|
||||
rs.getString("display_name"),
|
||||
Role.parse(rs.getString("role")),
|
||||
localDateTime(rs.getTimestamp("registration_date")),
|
||||
localDateTime(rs.getTimestamp("last_login_date")),
|
||||
rs.getObject("steam_id") != null,
|
||||
!rs.getBoolean("enabled"),
|
||||
rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null,
|
||||
rs.getString("status"));
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AdminUserRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
private static LocalDateTime localDateTime(Timestamp timestamp) {
|
||||
return timestamp != null ? timestamp.toLocalDateTime() : null;
|
||||
}
|
||||
|
||||
private static Object[] matchArgs(String query) {
|
||||
String pattern = "%" + query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
|
||||
return new Object[]{query, pattern, pattern};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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, String status, int page, int size) {
|
||||
Object[] match = matchArgs(query);
|
||||
return jdbc.query("""
|
||||
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at,
|
||||
status
|
||||
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, String status) {
|
||||
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;
|
||||
}
|
||||
|
||||
public Optional<TargetState> findState(long userId) {
|
||||
return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?",
|
||||
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
|
||||
"PENDING".equals(rs.getString("status")), "UNCONFIRMED".equals(rs.getString("status"))),
|
||||
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.
|
||||
*
|
||||
* @return false when the user already had this state
|
||||
*/
|
||||
@Transactional
|
||||
public boolean setBlocked(long userId, boolean blocked, Instant now) {
|
||||
boolean changed = jdbc.update("UPDATE users SET enabled = ? WHERE id = ? AND enabled = ?", !blocked, userId,
|
||||
blocked) > 0;
|
||||
if (blocked) {
|
||||
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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.AdminUserPage;
|
||||
import auth_service.modules.admin.model.TargetState;
|
||||
import auth_service.modules.admin.repo.AdminUserRepository;
|
||||
import auth_service.shared.account.AccountDeleter;
|
||||
import auth_service.shared.account.AccountStatePublisher;
|
||||
import auth_service.shared.account.PasswordResetSender;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.mail.AccountMailer;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
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;
|
||||
|
||||
@Service
|
||||
@RequiresRole(Role.PLATFORM_ADMIN)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AdminUserService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private final AdminUserRepository adminUserRepository;
|
||||
private final AdminAuditLog adminAuditLog;
|
||||
private final AccountDeleter accountDeleter;
|
||||
private final AccountStatePublisher accountStatePublisher;
|
||||
private final AccountMailer accountMailer;
|
||||
private final PasswordResetSender passwordResetSender;
|
||||
|
||||
public AdminUserPage findUsers(String query, String status, int page, int size) {
|
||||
String search = query == null ? "" : query.trim();
|
||||
String withStatus = status == null ? "" : status;
|
||||
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
|
||||
int pageNumber = Math.max(page, 0);
|
||||
long total = adminUserRepository.count(search, withStatus);
|
||||
return new AdminUserPage(adminUserRepository.findPage(search, withStatus, pageNumber, pageSize), pageNumber,
|
||||
pageSize, total, (int) Math.ceil((double) total / pageSize));
|
||||
}
|
||||
|
||||
public long countPending() {
|
||||
return adminUserRepository.count("", "PENDING");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@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) {
|
||||
TargetState target = notARequest(checkedTarget(actorUserId, targetUserId));
|
||||
if (target.deleted()) {
|
||||
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
||||
}
|
||||
boolean changed = adminUserRepository.setBlocked(targetUserId, blocked, Instant.now());
|
||||
accountStatePublisher.publish(targetUserId);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same flag and nightly cleanup as "Delete my account". A user flagged before is not an error. No
|
||||
* 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) {
|
||||
notARequest(checkedTarget(actorUserId, targetUserId));
|
||||
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
||||
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
||||
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only for a request whose address was never confirmed: a hard delete, no mail. The nightly purge does the same
|
||||
* after 7 days.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteUnconfirmed(long actorUserId, long targetUserId) {
|
||||
if (!checkedTarget(actorUserId, targetUserId).unconfirmed()
|
||||
|| !adminUserRepository.deleteUnconfirmed(targetUserId)) {
|
||||
throw new AdminActionRefusedException("NOT_UNCONFIRMED", "The registration is confirmed already");
|
||||
}
|
||||
adminAuditLog.record(actorUserId, "DELETE_UNCONFIRMED", targetUserId, null);
|
||||
log.info("Admin {} deleted the unconfirmed registration of user {}", actorUserId, targetUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same mail as "Forgot password". The admin never sees or sets a password.
|
||||
*/
|
||||
@Transactional
|
||||
public void sendPasswordReset(long actorUserId, long targetUserId) {
|
||||
checkedTarget(actorUserId, targetUserId);
|
||||
if (!passwordResetSender.sendByAdmin(targetUserId)) {
|
||||
throw new AdminActionRefusedException("NOT_ACTIVE", "Only an active account can get a reset mail");
|
||||
}
|
||||
adminAuditLog.record(actorUserId, "SEND_PASSWORD_RESET", targetUserId, null);
|
||||
log.info("Admin {} sent a password reset to user {}", actorUserId, targetUserId);
|
||||
}
|
||||
|
||||
private TargetState checkedTarget(long actorUserId, long targetUserId) {
|
||||
if (actorUserId == targetUserId) {
|
||||
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
|
||||
}
|
||||
TargetState target = adminUserRepository.findState(targetUserId)
|
||||
.orElseThrow(() -> new TargetUserNotFoundException(targetUserId));
|
||||
if (target.role() == Role.PLATFORM_ADMIN) {
|
||||
throw new AdminActionRefusedException("TARGET_IS_ADMIN", "Admins cannot block or delete another admin");
|
||||
}
|
||||
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,18 @@
|
||||
package auth_service.modules.admin.service;
|
||||
|
||||
import auth_service.modules.admin.repo.AdminAuditRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogService implements AdminAuditLog {
|
||||
|
||||
private final AdminAuditRepository adminAuditRepository;
|
||||
|
||||
@Override
|
||||
public void record(long actorUserId, String action, Long targetUserId, String detail) {
|
||||
adminAuditRepository.insert(actorUserId, action, targetUserId, detail);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth_service.modules.hint.api;
|
||||
|
||||
import auth_service.modules.hint.api.dto.request.AutoHintRequest;
|
||||
import auth_service.modules.hint.api.dto.request.CreateHintRequest;
|
||||
import auth_service.modules.hint.api.dto.response.AdminHintListResponse;
|
||||
import auth_service.modules.hint.api.dto.response.AdminHintResponse;
|
||||
import auth_service.modules.hint.api.dto.response.AutoHintResponse;
|
||||
import auth_service.modules.hint.api.dto.response.CreatedHintResponse;
|
||||
import auth_service.modules.hint.model.AdminHint;
|
||||
import auth_service.modules.hint.model.Hint;
|
||||
import auth_service.modules.hint.model.HintTarget;
|
||||
import auth_service.modules.hint.model.NewHint;
|
||||
import auth_service.modules.hint.service.AdminHintService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
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.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/hints")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminHintController {
|
||||
|
||||
private final AdminHintService adminHintService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<AdminHintListResponse> findAll() {
|
||||
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
|
||||
.map(AdminHintController::toApi).toList(),
|
||||
adminHintService.findAutoHints().stream()
|
||||
.map(state -> new AutoHintResponse(state.key().name(), state.enabled())).toList()));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody CreateHintRequest request) {
|
||||
long id = adminHintService.create(principal.getIdAsLong(),
|
||||
new NewHint(request.textEn(), request.textDe(), request.kind(), request.startsAt(), request.endsAt(),
|
||||
new HintTarget(Boolean.TRUE.equals(request.targetNoSteam()), request.targetRole(),
|
||||
request.targetNewDays(), Boolean.TRUE.equals(request.targetEmptyLibrary()))));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
|
||||
}
|
||||
|
||||
@PutMapping(value = "/{hintId}/end")
|
||||
public ResponseEntity<Void> end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
||||
adminHintService.end(principal.getIdAsLong(), hintId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/auto/{key}")
|
||||
public ResponseEntity<Void> setAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key,
|
||||
@Valid @RequestBody AutoHintRequest request) {
|
||||
adminHintService.setAutoHintEnabled(principal.getIdAsLong(), key, request.enabled());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static AdminHintResponse toApi(AdminHint admin) {
|
||||
Hint hint = admin.hint();
|
||||
HintTarget target = hint.target();
|
||||
return new AdminHintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
|
||||
hint.endsAt(), admin.status().name(), admin.createdBy(), admin.createdAt(), admin.doneCount(),
|
||||
target.noSteam(), target.role() == null ? null : target.role().name(), target.newDays(),
|
||||
target.emptyLibrary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth_service.modules.hint.api;
|
||||
|
||||
import auth_service.modules.hint.api.dto.response.HintListResponse;
|
||||
import auth_service.modules.hint.api.dto.response.HintResponse;
|
||||
import auth_service.modules.hint.model.AutoHint;
|
||||
import auth_service.modules.hint.model.Hint;
|
||||
import auth_service.modules.hint.service.HintService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/hints")
|
||||
@RequiredArgsConstructor
|
||||
public class HintController {
|
||||
|
||||
private final HintService hintService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
long userId = principal.getIdAsLong();
|
||||
return ResponseEntity.ok(new HintListResponse(
|
||||
hintService.activeHints(userId, principal.role()).stream().map(HintController::toApi).toList(),
|
||||
hintService.autoHints(userId).stream().map(AutoHint::name).toList()));
|
||||
}
|
||||
|
||||
@PutMapping(value = "/auto/{key}/snooze")
|
||||
public ResponseEntity<Void> snoozeAuto(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String key) {
|
||||
hintService.snoozeAutoHint(principal.getIdAsLong(), key);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/auto/{key}/done")
|
||||
public ResponseEntity<Void> hideAuto(@AuthenticationPrincipal UserPrincipal principal, @PathVariable String key) {
|
||||
hintService.hideAutoHintForGood(principal.getIdAsLong(), key);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/{hintId}/done")
|
||||
public ResponseEntity<Void> markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
||||
hintService.markDone(principal.getIdAsLong(), hintId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static HintResponse toApi(Hint hint) {
|
||||
return new HintResponse(hint.id(), hint.textEn(), hint.textDe(), hint.kind().name(), hint.startsAt(),
|
||||
hint.endsAt(), hint.target().emptyLibrary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth_service.modules.hint.api;
|
||||
|
||||
import auth_service.modules.hint.exception.HintNotFoundException;
|
||||
import auth_service.modules.hint.exception.InvalidHintException;
|
||||
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 = {HintController.class, AdminHintController.class})
|
||||
@Order(value = 1)
|
||||
public class HintExceptionController {
|
||||
|
||||
@ExceptionHandler(HintNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(HintNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("HINT_NOT_FOUND", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidHintException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(InvalidHintException ex) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("INVALID_HINT", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.hint.api.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AutoHintRequest(@NotNull Boolean enabled) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package auth_service.modules.hint.api.dto.request;
|
||||
|
||||
import auth_service.modules.hint.model.HintKind;
|
||||
import auth_service.shared.security.Role;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Plain text with {@code **bold**} and {@code [text](url)}; the Frontend renders it, never as HTML.
|
||||
* Target fields are optional, missing = every user. Wrappers, because a missing primitive is a 400.
|
||||
* {@code targetRole} means this role and higher.
|
||||
*/
|
||||
public record CreateHintRequest(@NotBlank @Size(max = 500) String textEn, @Size(max = 500) String textDe,
|
||||
@NotNull HintKind kind, @NotNull Instant startsAt, @NotNull Instant endsAt,
|
||||
Boolean targetNoSteam, Role targetRole, @Min(1) @Max(365) Integer targetNewDays,
|
||||
Boolean targetEmptyLibrary) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.hint.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminHintListResponse(List<AdminHintResponse> results, List<AutoHintResponse> automatic) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.hint.api.dto.response;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AdminHintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
|
||||
String status, long createdBy, Instant createdAt, long doneCount,
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package auth_service.modules.hint.api.dto.response;
|
||||
|
||||
public record CreatedHintResponse(long id) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.hint.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.hint.api.dto.response;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record HintResponse(long id, String textEn, String textDe, String kind, Instant startsAt, Instant endsAt,
|
||||
boolean emptyLibraryOnly) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package auth_service.modules.hint.exception;
|
||||
|
||||
public class HintNotFoundException extends RuntimeException {
|
||||
public HintNotFoundException(long hintId) {
|
||||
super("No hint with id " + hintId);
|
||||
}
|
||||
|
||||
public HintNotFoundException(String key) {
|
||||
super("No automatic hint " + key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.hint.exception;
|
||||
|
||||
public class InvalidHintException extends RuntimeException {
|
||||
public InvalidHintException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.hint.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AdminHint(Hint hint, HintStatus status, long createdBy, Instant createdAt, long doneCount) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package auth_service.modules.hint.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* @param textDe null: German users see {@code textEn}
|
||||
*/
|
||||
public record Hint(long id, String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
|
||||
HintTarget target) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth_service.modules.hint.model;
|
||||
|
||||
/**
|
||||
* DISMISS closes by itself after 30 s or with X; ACCEPT stays until the user clicks "OK".
|
||||
*/
|
||||
public enum HintKind {
|
||||
DISMISS, ACCEPT
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth_service.modules.hint.model;
|
||||
|
||||
/**
|
||||
* Computed with the server's clock, so the admin page does not depend on the browser's.
|
||||
*/
|
||||
public enum HintStatus {
|
||||
PLANNED, ACTIVE, ENDED
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.hint.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record NewHint(String textEn, String textDe, HintKind kind, Instant startsAt, Instant endsAt,
|
||||
HintTarget target) {
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package auth_service.modules.hint.repo;
|
||||
|
||||
import auth_service.modules.hint.model.AdminHint;
|
||||
import auth_service.modules.hint.model.AutoHint;
|
||||
import auth_service.modules.hint.model.AutoHintState;
|
||||
import auth_service.modules.hint.model.Hint;
|
||||
import auth_service.modules.hint.model.HintKind;
|
||||
import auth_service.modules.hint.model.HintStatus;
|
||||
import auth_service.modules.hint.model.HintTarget;
|
||||
import auth_service.modules.hint.model.NewHint;
|
||||
import auth_service.shared.security.Role;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Repository
|
||||
public class HintRepository {
|
||||
|
||||
private static final String COLUMNS = "h.id, h.text_en, h.text_de, h.kind, h.starts_at, h.ends_at, "
|
||||
+ "h.target_no_steam, h.target_role, h.target_new_days, h.target_empty_library";
|
||||
|
||||
private static final RowMapper<Hint> HINT = (rs, row) -> new Hint(
|
||||
rs.getLong("id"),
|
||||
rs.getString("text_en"),
|
||||
rs.getString("text_de"),
|
||||
HintKind.valueOf(rs.getString("kind")),
|
||||
rs.getTimestamp("starts_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;
|
||||
|
||||
public HintRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param role the active role of the caller; a target role matches this role and higher
|
||||
*/
|
||||
public List<Hint> findActiveFor(long userId, Role role, Instant now) {
|
||||
Timestamp at = Timestamp.from(now);
|
||||
// registration_date is naive UTC
|
||||
LocalDateTime utc = LocalDateTime.ofInstant(now, ZoneOffset.UTC);
|
||||
return jdbc.query("""
|
||||
SELECT %s FROM hint h JOIN users u ON u.id = ?
|
||||
WHERE h.starts_at <= ? AND h.ends_at > ?
|
||||
AND NOT EXISTS (SELECT 1 FROM hint_done d WHERE d.hint_id = h.id AND d.user_id = u.id)
|
||||
AND (NOT h.target_no_steam OR u.steam_id IS NULL)
|
||||
AND (h.target_role IS NULL
|
||||
OR CASE h.target_role WHEN 'USER' THEN 0 WHEN 'AUDITOR' THEN 1 ELSE 2 END <= ?)
|
||||
AND (h.target_new_days IS NULL
|
||||
OR u.registration_date > ?::timestamp - make_interval(days => h.target_new_days))
|
||||
ORDER BY h.starts_at DESC, h.id DESC""".formatted(COLUMNS), HINT, userId, at, at, role.ordinal(),
|
||||
utc);
|
||||
}
|
||||
|
||||
public List<AutoHint> findVisibleAutoHints(long userId, Instant now) {
|
||||
return jdbc.query("""
|
||||
SELECT a.hint_key FROM auto_hint a
|
||||
WHERE a.enabled
|
||||
AND NOT EXISTS (SELECT 1 FROM auto_hint_hidden x
|
||||
WHERE x.user_id = ? AND x.hint_key = a.hint_key
|
||||
AND (x.hidden_until IS NULL OR x.hidden_until > ?))""",
|
||||
(rs, row) -> AutoHint.valueOf(rs.getString("hint_key")), userId, Timestamp.from(now))
|
||||
.stream().sorted().toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param until null: for good. A snooze never undoes "for good".
|
||||
*/
|
||||
public void hideAutoHint(long userId, AutoHint key, Instant until) {
|
||||
jdbc.update("""
|
||||
INSERT INTO auto_hint_hidden (user_id, hint_key, hidden_until) VALUES (?, ?, ?)
|
||||
ON CONFLICT (user_id, hint_key) DO UPDATE SET hidden_until =
|
||||
CASE WHEN auto_hint_hidden.hidden_until IS NULL THEN NULL ELSE EXCLUDED.hidden_until END""",
|
||||
userId, key.name(), until == null ? null : Timestamp.from(until));
|
||||
}
|
||||
|
||||
public List<AutoHintState> findAutoHints() {
|
||||
return jdbc.query("SELECT hint_key, enabled FROM auto_hint",
|
||||
(rs, row) -> new AutoHintState(AutoHint.valueOf(rs.getString("hint_key")), rs.getBoolean("enabled")))
|
||||
.stream().sorted(Comparator.comparing(AutoHintState::key)).toList();
|
||||
}
|
||||
|
||||
public void setAutoHintEnabled(AutoHint key, boolean enabled) {
|
||||
jdbc.update("UPDATE auto_hint SET enabled = ? WHERE hint_key = ?", enabled, key.name());
|
||||
}
|
||||
|
||||
public List<AdminHint> findAll(Instant now) {
|
||||
Timestamp at = Timestamp.from(now);
|
||||
return jdbc.query("""
|
||||
SELECT %s, h.created_by, h.created_at,
|
||||
CASE WHEN h.ends_at <= ? THEN 'ENDED' WHEN h.starts_at > ? THEN 'PLANNED' ELSE 'ACTIVE' END
|
||||
AS status,
|
||||
(SELECT COUNT(*) FROM hint_done d WHERE d.hint_id = h.id) AS done_count
|
||||
FROM hint h ORDER BY h.created_at DESC, h.id DESC""".formatted(COLUMNS),
|
||||
(rs, row) -> new AdminHint(HINT.mapRow(rs, row), HintStatus.valueOf(rs.getString("status")),
|
||||
rs.getLong("created_by"), rs.getTimestamp("created_at").toInstant(), rs.getLong("done_count")),
|
||||
at, at);
|
||||
}
|
||||
|
||||
public boolean exists(long hintId) {
|
||||
return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM hint WHERE id = ?)", Boolean.class,
|
||||
hintId));
|
||||
}
|
||||
|
||||
public long insert(NewHint hint, long createdBy) {
|
||||
HintTarget target = hint.target();
|
||||
return Objects.requireNonNull(jdbc.queryForObject("""
|
||||
INSERT INTO hint (text_en, text_de, kind, starts_at, ends_at, created_by, target_no_steam, target_role,
|
||||
target_new_days, target_empty_library)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id""", Long.class, hint.textEn(), hint.textDe(), hint.kind().name(),
|
||||
Timestamp.from(hint.startsAt()), Timestamp.from(hint.endsAt()), createdBy, target.noSteam(),
|
||||
target.role() == null ? null : target.role().name(), target.newDays(), target.emptyLibrary()));
|
||||
}
|
||||
|
||||
public void markDone(long hintId, long userId) {
|
||||
jdbc.update("INSERT INTO hint_done (hint_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", hintId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A planned hint ends before it starts, so it never shows.
|
||||
*
|
||||
* @return false when the hint had already ended
|
||||
*/
|
||||
public boolean end(long hintId, Instant now) {
|
||||
Timestamp at = Timestamp.from(now);
|
||||
return jdbc.update("UPDATE hint SET ends_at = ?, starts_at = LEAST(starts_at, ?) WHERE id = ? AND ends_at > ?",
|
||||
at, at, hintId, at) > 0;
|
||||
}
|
||||
|
||||
// Not Role.parse: it turns null into USER, and null here means "every role".
|
||||
private static Role role(String name) {
|
||||
return name == null ? null : Role.valueOf(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package auth_service.modules.hint.service;
|
||||
|
||||
import auth_service.modules.hint.exception.HintNotFoundException;
|
||||
import auth_service.modules.hint.exception.InvalidHintException;
|
||||
import auth_service.modules.hint.model.AdminHint;
|
||||
import auth_service.modules.hint.model.AutoHint;
|
||||
import auth_service.modules.hint.model.AutoHintState;
|
||||
import auth_service.modules.hint.model.NewHint;
|
||||
import auth_service.modules.hint.repo.HintRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AdminHintService {
|
||||
|
||||
private final HintRepository hintRepository;
|
||||
private final AdminAuditLog adminAuditLog;
|
||||
|
||||
public List<AdminHint> findAll() {
|
||||
return hintRepository.findAll(Instant.now());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long create(long actorUserId, NewHint hint) {
|
||||
if (!hint.endsAt().isAfter(hint.startsAt())) {
|
||||
throw new InvalidHintException("The end must be after the start");
|
||||
}
|
||||
if (!hint.endsAt().isAfter(Instant.now())) {
|
||||
throw new InvalidHintException("The end must be in the future");
|
||||
}
|
||||
String textDe = hint.textDe() == null || hint.textDe().isBlank() ? null : hint.textDe();
|
||||
long id = hintRepository.insert(new NewHint(hint.textEn(), textDe, hint.kind(), hint.startsAt(), hint.endsAt(),
|
||||
hint.target()), actorUserId);
|
||||
adminAuditLog.record(actorUserId, "HINT_CREATE", null, String.valueOf(id));
|
||||
log.info("Admin {} created hint {}", actorUserId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void end(long actorUserId, long hintId) {
|
||||
if (!hintRepository.exists(hintId)) {
|
||||
throw new HintNotFoundException(hintId);
|
||||
}
|
||||
if (hintRepository.end(hintId, Instant.now())) {
|
||||
adminAuditLog.record(actorUserId, "HINT_END", null, String.valueOf(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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package auth_service.modules.hint.service;
|
||||
|
||||
import auth_service.modules.hint.exception.HintNotFoundException;
|
||||
import auth_service.modules.hint.model.AutoHint;
|
||||
import auth_service.modules.hint.model.Hint;
|
||||
import auth_service.modules.hint.repo.HintRepository;
|
||||
import auth_service.shared.security.RequiresRole;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiresRole(Role.USER)
|
||||
@RequiredArgsConstructor
|
||||
public class HintService {
|
||||
|
||||
private static final Duration SNOOZE = Duration.ofDays(7);
|
||||
|
||||
private final HintRepository hintRepository;
|
||||
|
||||
public List<Hint> activeHints(long userId, Role role) {
|
||||
return hintRepository.findActiveFor(userId, role, Instant.now());
|
||||
}
|
||||
|
||||
public List<AutoHint> autoHints(long userId) {
|
||||
return hintRepository.findVisibleAutoHints(userId, Instant.now());
|
||||
}
|
||||
|
||||
public void snoozeAutoHint(long userId, String key) {
|
||||
hintRepository.hideAutoHint(userId, AutoHint.parse(key), Instant.now().plus(SNOOZE));
|
||||
}
|
||||
|
||||
public void hideAutoHintForGood(long userId, String key) {
|
||||
hintRepository.hideAutoHint(userId, AutoHint.parse(key), null);
|
||||
}
|
||||
|
||||
public void markDone(long userId, long hintId) {
|
||||
if (!hintRepository.exists(hintId)) {
|
||||
throw new HintNotFoundException(hintId);
|
||||
}
|
||||
hintRepository.markDone(hintId, userId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.login.api;
|
||||
|
||||
import auth_service.modules.login.api.dto.request.LoginRequest;
|
||||
import auth_service.modules.login.api.dto.request.ViewAsRequest;
|
||||
import auth_service.modules.login.api.dto.response.LoginResponse;
|
||||
import auth_service.modules.login.api.mapper.ApiMapper;
|
||||
import auth_service.modules.login.model.Session;
|
||||
@@ -62,10 +63,28 @@ public class LoginController {
|
||||
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
|
||||
Optional<Session> session = refreshTokenService.refresh(refreshToken);
|
||||
if (session.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.headers(cookies(authCookies.cleared()))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("SESSION_EXPIRED", "Please log in again"));
|
||||
return sessionExpired();
|
||||
}
|
||||
return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build();
|
||||
}
|
||||
|
||||
private ResponseEntity<ErrorResponse> sessionExpired() {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.headers(cookies(authCookies.cleared()))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("SESSION_EXPIRED", "Please log in again"));
|
||||
}
|
||||
|
||||
/**
|
||||
* "View as": the same as a refresh, with the given role for this login. Under /api/auth, because the refresh
|
||||
* cookie is sent only there.
|
||||
*/
|
||||
@PostMapping("/view-as")
|
||||
public ResponseEntity<?> viewAs(@RequestBody ViewAsRequest request,
|
||||
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
|
||||
Optional<Session> session = refreshTokenService.switchRole(refreshToken, request.role());
|
||||
if (session.isEmpty()) {
|
||||
return sessionExpired();
|
||||
}
|
||||
return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package auth_service.modules.login.api;
|
||||
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
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.RoleNotAllowedException;
|
||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +41,21 @@ public class LoginExceptionController {
|
||||
.body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccountBlockedException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(AccountBlockedException ex) {
|
||||
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)
|
||||
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
|
||||
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidCredentialsException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(InvalidCredentialsException ex) {
|
||||
var body = new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.login.api.dto.request;
|
||||
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
@@ -7,4 +8,8 @@ import jakarta.validation.constraints.NotNull;
|
||||
* DTO (Data Transfer Object) representing the incoming login request.
|
||||
*/
|
||||
public record LoginRequest(@NotNull String email, @NotNull @NotBlank String password) {
|
||||
|
||||
public LoginRequest {
|
||||
email = EmailAddress.normalize(email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.login.api.dto.request;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
/**
|
||||
* @param role null goes back to the own role
|
||||
*/
|
||||
public record ViewAsRequest(Role role) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.login.exception;
|
||||
|
||||
public class AccountBlockedException extends RuntimeException {
|
||||
public AccountBlockedException() {
|
||||
super("This account is blocked");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.login.exception;
|
||||
|
||||
public class RoleNotAllowedException extends RuntimeException {
|
||||
public RoleNotAllowedException() {
|
||||
super("You cannot act with a role above your own");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package auth_service.modules.login.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt) {
|
||||
/**
|
||||
* @param activeRole the "view as" role of this login, null for the stored role
|
||||
*/
|
||||
public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt,
|
||||
Role activeRole) {
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
package auth_service.modules.login.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class User {
|
||||
private long id;
|
||||
private String email;
|
||||
@ToString.Exclude
|
||||
private String password;
|
||||
private Long steamId;
|
||||
private boolean enabled;
|
||||
private boolean deleted;
|
||||
private boolean pending;
|
||||
@Builder.Default
|
||||
private Role role = Role.USER;
|
||||
private Instant roleChangedAt;
|
||||
|
||||
public boolean canLogin() {
|
||||
return enabled && !deleted && !pending;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import java.util.Optional;
|
||||
public interface MinimalUserRepository {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -18,5 +18,11 @@ public interface MinimalUserRepository {
|
||||
|
||||
Optional<User> findById(long userId);
|
||||
|
||||
/**
|
||||
* Reads the password hash and holds a lock on the row until the transaction ends, so a password change commits
|
||||
* before or after the caller's writes, never between.
|
||||
*/
|
||||
Optional<String> lockPasswordHash(long userId);
|
||||
|
||||
void updateLoginDate(long userId);
|
||||
}
|
||||
+33
-28
@@ -1,12 +1,11 @@
|
||||
package auth_service.modules.login.persistence;
|
||||
|
||||
import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
||||
import auth_service.modules.login.persistence.mapper.EntityMapper;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.transaction.Transactional;
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Clock;
|
||||
@@ -14,39 +13,45 @@ import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
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
|
||||
public Optional<User> findByEmail(String email) {
|
||||
var criteriaBuilder = entityManager.getCriteriaBuilder();
|
||||
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);
|
||||
return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
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
|
||||
public void updateLoginDate(long userId) {
|
||||
var userEntity = entityManager.find(MinimalUserEntity.class, userId);
|
||||
|
||||
if (userEntity != null) {
|
||||
userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC()));
|
||||
entityManager.flush();
|
||||
}
|
||||
jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.login.persistence;
|
||||
|
||||
import auth_service.modules.login.model.RefreshToken;
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
@@ -8,7 +9,15 @@ import java.util.UUID;
|
||||
|
||||
public interface RefreshTokenRepository {
|
||||
|
||||
void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt);
|
||||
/**
|
||||
* @param activeRole null for the stored role
|
||||
*/
|
||||
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.
|
||||
|
||||
+12
-1
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.login.persistence;
|
||||
|
||||
import auth_service.modules.login.model.RefreshToken;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
|
||||
import auth_service.modules.login.persistence.mapper.EntityMapper;
|
||||
import jakarta.persistence.EntityManager;
|
||||
@@ -19,16 +20,26 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Override
|
||||
public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt) {
|
||||
public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole) {
|
||||
RefreshTokenEntity entity = new RefreshTokenEntity();
|
||||
entity.setUserId(userId);
|
||||
entity.setFamilyId(familyId);
|
||||
entity.setTokenHash(tokenHash);
|
||||
entity.setCreatedAt(createdAt);
|
||||
entity.setExpiresAt(expiresAt);
|
||||
entity.setActiveRole(activeRole == null ? null : activeRole.name());
|
||||
entityManager.persist(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Long> findUserIdByHash(String tokenHash) {
|
||||
return entityManager.createQuery("SELECT t.userId FROM RefreshTokenEntity t WHERE t.tokenHash = :hash", Long.class)
|
||||
.setParameter("hash", tokenHash)
|
||||
.getResultList()
|
||||
.stream()
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RefreshToken> findByHashForUpdate(String tokenHash) {
|
||||
return entityManager.createQuery(
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package auth_service.modules.login.persistence.entities;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -42,4 +42,7 @@ public class RefreshTokenEntity {
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "active_role")
|
||||
private String activeRole;
|
||||
}
|
||||
@@ -1,31 +1,17 @@
|
||||
package auth_service.modules.login.persistence.mapper;
|
||||
|
||||
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.shared.security.Role;
|
||||
|
||||
public abstract class 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());
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public static RefreshToken mapToModel(RefreshTokenEntity entity) {
|
||||
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
|
||||
entity.getRotatedAt(), entity.getRevokedAt());
|
||||
entity.getRotatedAt(), entity.getRevokedAt(),
|
||||
entity.getActiveRole() == null ? null : Role.parse(entity.getActiveRole()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import auth_service.shared.security.AttemptLimiter;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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
|
||||
@@ -37,7 +37,7 @@ public class LoginAttemptService {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
package auth_service.modules.login.service;
|
||||
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.shared.security.Public;
|
||||
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.model.Session;
|
||||
import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Public
|
||||
@Slf4j
|
||||
public class LoginService {
|
||||
private final MinimalUserRepository minimalUserRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenGenerator tokenGenerator;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
|
||||
private final String dummyHash;
|
||||
|
||||
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
|
||||
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService,
|
||||
RefreshTokenService refreshTokenService) {
|
||||
LoginAttemptService loginAttemptService, RefreshTokenService refreshTokenService,
|
||||
CacheManager cacheManager) {
|
||||
this.minimalUserRepository = minimalUserRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.tokenGenerator = tokenGenerator;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
this.cacheManager = cacheManager;
|
||||
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a user and starts a session. Unknown email, wrong password and disabled
|
||||
* account all fail with the same InvalidCredentialsException.
|
||||
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
|
||||
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right
|
||||
* password. An unconfirmed user has no password and fails like a wrong one.
|
||||
*
|
||||
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
|
||||
* so its login is revoked; else it would stay valid unseen for 30 days.
|
||||
@@ -50,15 +56,29 @@ public class LoginService {
|
||||
String hash = foundUser.map(User::getPassword).orElse(dummyHash);
|
||||
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash);
|
||||
|
||||
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) {
|
||||
if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) {
|
||||
throw new InvalidCredentialsException("Invalid email or password");
|
||||
}
|
||||
// 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()) {
|
||||
loginAttemptService.recordSuccess(email, clientIp);
|
||||
throw new AccountBlockedException();
|
||||
}
|
||||
|
||||
User user = foundUser.get();
|
||||
loginAttemptService.recordSuccess(email, clientIp);
|
||||
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);
|
||||
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId());
|
||||
return new Session(accessToken, refreshTokenService.startFamily(user.getId()));
|
||||
return refreshTokenService.startSession(user.getId(), hash);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user