Compare commits

...
53 Commits
Author SHA1 Message Date
lobo 4701cebece chore: remove unused dev compose and prometheus dependency
Build, Test & Push Auth Service / quality-check (push) Successful in 1m41s
Build, Test & Push Auth Service / docker (push) Successful in 1m40s
Prometheus is not in use. Only /actuator/health is exposed.
2026-10-14 15:22:20 +02:00
lobo c124d0e5d2 docs(readme): match readme to current code
Build, Test & Push Auth Service / quality-check (push) Successful in 1m4s
Build, Test & Push Auth Service / docker (push) Successful in 2m4s
Readme had drifted from the code. Update the repo list, endpoints,
JWKS and cookie rules, env vars, CI and dev data.
2026-10-14 14:51:40 +02:00
lobo c362e7047e test(cors): pin Access-Control-Max-Age 3600 on preflight
Build, Test & Push Auth Service / quality-check (push) Successful in 1m5s
Build, Test & Push Auth Service / docker (push) Successful in 1m32s
Refs #47
2026-10-12 09:25:49 +02:00
lobo 734ff2e07c feat(configuration): add request timing log
Build, Test & Push Auth Service / quality-check (push) Successful in 1m3s
Build, Test & Push Auth Service / docker (push) Successful in 1m33s
One INFO line per API request: method, handler pattern, status, ms.
No ids or query strings in the line. Refs #40.
2026-10-11 11:05:18 +02:00
lobo d842d9e865 feat(account): push public profile flag and sections to Core
Build, Test & Push Auth Service / quality-check (push) Successful in 1m4s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s
2026-10-06 16:22:22 +02:00
lobo d924c09953 feat(user): add owner-lists opt-in setting and user listing endpoint
Build, Test & Push Auth Service / quality-check (push) Successful in 1m5s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
2026-10-06 13:19:58 +02:00
lobo d9b816904a feat(dashboard): add up-next tile to saved dashboard layouts
Build, Test & Push Auth Service / quality-check (push) Successful in 1m25s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s
2026-10-06 09:06:51 +02:00
lobo 395b8e6f99 feat(user): add chart forms for TOP_GAMES, ACTIVITY, ACTIVITY_GAMES
Build, Test & Push Auth Service / quality-check (push) Successful in 1m0s
Build, Test & Push Auth Service / docker (push) Successful in 1m30s
2026-10-04 21:06:21 +02:00
lobo 365b69feb4 feat(admin): let admins change user roles
V32 adds role_changed_at and the role_change log. Role changes lock
the admin rows, so there is always one admin left; the last admin
cannot delete their own account. Login and refresh read the role
under the user lock. Tokens issued before a role change are refused.
2026-10-04 20:09:22 +02:00
lobo 3410cbc598 feat(settings): store chart form choice per stat group
Build, Test & Push Auth Service / quality-check (push) Successful in 1m0s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s
2026-10-04 10:43:02 +02:00
lobo 19463a1a4d feat(hint): target groups and automatic hints, V30
Build, Test & Push Auth Service / quality-check (push) Successful in 59s
Build, Test & Push Auth Service / docker (push) Successful in 1m34s
Hint targets combined with AND: without Steam, role and higher, new
users by registration_date, empty library flag. Four automatic hints
(NO_STEAM, EMPTY_LIBRARY, PILE_GREW, LINK_QUEUE), on by default; the
admin turns each on or off (audited). Users snooze one for 7 days (X)
or hide it for good (tick), stored on the server. Hint text may use
**bold** and [text](url).
2026-10-02 14:36:49 +02:00
lobo 5a2552ddf6 feat(password): add reset and change password flows
Build, Test & Push Auth Service / quality-check (push) Successful in 53s
Build, Test & Push Auth Service / docker (push) Successful in 1m30s
Forgot password with a single-use reset link (1 hour, ACTIVE users
only, same answer for every address), change password (current session
stays, others end), admin reset mail with audit row, 'password
changed' mail. Login and refresh wait for a running password change.
2026-10-02 10:35:08 +02:00
lobo 6e7fb1e066 feat(account): send display name in account state to Core
Build, Test & Push Auth Service / quality-check (push) Successful in 49s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
The account state push carries displayName. It is published when the
name changes. Contract test updated.
2026-10-02 09:10:56 +02:00
lobo 1b7bafd9f0 feat(mail): send broadcasts to every active user
Remove the platform_mails opt-out. Broadcasts go to all active users.
V28 drops the column; the PUT /api/users/settings/platform-mails
endpoint and the footer line about turning mails off are gone.
2026-10-02 07:53:35 +02:00
lobo 0af76da9c7 feat(registration): add email confirmation flow
Build, Test & Push Auth Service / quality-check (push) Successful in 58s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
Registrations become two-phase: first UNCONFIRMED with a 24-hour
single-use email link, then PENDING after confirmation and password
entry. Prevents registering with someone else's address. Admins see
unconfirmed requests and can delete them; they cannot accept them.
2026-10-01 16:20:51 +02:00
lobo 878ede914b feat(settings): reduce animations appearance flag
Build, Test & Push Auth Service / quality-check (push) Successful in 53s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
2026-10-01 15:09:25 +02:00
lobo a60011ce9f feat(mail): add broadcast mail for admins
Adds endpoints for PLATFORM_ADMIN to send mail to all active users or
picked ones. Broadcasts and account mails share the same outbox;
broadcasts take the rest of the daily limit after 10 mails are kept free
for account mail. Users can opt out via settings. History is kept in
mail_broadcast with recipient, sent and failed counts. The footer says
where to opt out.
2026-10-01 14:12:34 +02:00
lobo e72222a37f feat(profile): role claim in the profile ticket
Build, Test & Push Auth Service / quality-check (push) Successful in 54s
Build, Test & Push Auth Service / docker (push) Successful in 1m29s
The public profile ticket now carries the user's role so Core can check
ownership of shop items. PLATFORM_ADMIN gets every catalog item for free.
The role comes from the users table; a ticket without the claim means USER.
2026-10-01 13:14:27 +02:00
lobo 6b6ac44551 feat(user): add welcome-seen-at timestamp for welcome page tracking
Null means the welcome page was not seen yet. Existing users are backfilled
at migration time so only newly registered users see the page.
2026-10-01 10:44:22 +02:00
lobo 2b2f45b9af feat(news): add news endpoints and admin CRUD
Build, Test & Push Auth Service / quality-check (push) Successful in 51s
Build, Test & Push Auth Service / docker (push) Successful in 1m26s
Users see new entries once in a dialog after publication.
Admins manage entries via /api/admin/news with full audit trail.
2026-10-01 10:03:07 +02:00
lobo 39a4d5ae49 feat(registration): registration waits for the admin, no invite code
Build, Test & Push Auth Service / quality-check (push) Successful in 50s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
2026-10-01 09:18:12 +02:00
lobo 1008034e82 feat(admin): accept or deny registrations 2026-10-01 09:18:12 +02:00
lobo 1cac0c6b81 feat(mail): mails at registration, block and delete
Build, Test & Push Auth Service / quality-check (push) Successful in 1m12s
Build, Test & Push Auth Service / docker (push) Successful in 1m22s
2026-10-01 08:30:24 +02:00
lobo 47a03d2e6a feat(mail): outbox, SMTP sender and config 2026-10-01 08:30:24 +02:00
lobo fd79c63441 chore(security)!: drop the userId and typ claims and tokens without aud
Build, Test & Push Auth Service / quality-check (push) Successful in 46s
Build, Test & Push Auth Service / docker (push) Successful in 1m27s
2026-09-30 15:00:47 +02:00
lobo 9f56742ff6 refactor: item 29 cleanup
A10 A11 A12 A13 A14 A15 X10 X11 X12: role check denies outside a request, no API versioning, open-in-view off, no passwords in toString, X-Forwarded-For only from private peers, CORS from APP_FRONTEND_URL, non-root image.
2026-09-30 15:00:47 +02:00
lobo 89e2fa3df2 refactor(user): check only the shape of a tile id
Core and the Frontend skip ids they do not know; Core never shares the overview or the activity.
2026-09-30 15:00:47 +02:00
lobo 06f8089f5a feat(account): tell Core about Steam ID and block changes
Sent on the task executor after the commit, and every user again in the nightly cleanup. A failed call only warns.
2026-09-30 15:00:47 +02:00
lobo 3336181cdb test: one test container and one context per test kind
Build, Test & Push Auth Service / quality-check (push) Successful in 43s
Build, Test & Push Auth Service / docker (push) Successful in 1m29s
2026-09-30 13:41:57 +02:00
lobo 1f94c87a20 fix(security): refuse the access token of a blocked or deleted user
activeUsers cache, 30 s. Core still accepts the token until it expires; X4 (batch 4) tells Core.
2026-09-30 13:41:57 +02:00
lobo 509280da14 fix(cache): one cache config that expires after the write
publicStats is evicted on registration and Steam link/unlink. The cached user no longer holds the password hash. Tests no longer run with a 1 s TTL.
2026-09-30 13:41:56 +02:00
lobo cf21f4a654 refactor(settings): one load-change-save update for all settings 2026-09-30 13:41:56 +02:00
lobo fb51f836a8 fix(settings): give every user a settings row
V20 adds the row for old users, registration inserts one. The mapper fallback (en, system) disagreed with the column defaults.
2026-09-30 13:41:56 +02:00
lobo d082f3befd refactor(users): login and registration use JdbcTemplate for users 2026-09-30 13:41:56 +02:00
lobo b6be1879f0 test(architecture): apply the layer rules to repo packages 2026-09-30 13:41:56 +02:00
lobo afcfacb68b refactor(api): one error body, token audiences, user lookup by id
Build, Test & Push Auth Service / quality-check (push) Successful in 54s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
2026-09-29 14:48:28 +02:00
lobo 68919eaab4 fix(registration): check input and store emails in lower case 2026-09-29 13:27:31 +02:00
lobo ea4ae089ff feat(settings): store a favorite platform 2026-09-29 10:20:54 +02:00
lobo 315e22c220 feat(tiles): store dashboard and profile tile layouts
Build, Test & Push Auth Service / quality-check (push) Successful in 48s
Build, Test & Push Auth Service / docker (push) Successful in 1m26s
2026-09-28 15:03:16 +02:00
lobo dd4fcd1546 feat(profile): store the public profile look 2026-09-28 14:20:14 +02:00
lobo f86f3c2e63 feat(dev): add the local profile with generated keys and a dev user
Profile "local" starts without keys: LocalKeysEnvironmentPostProcessor
makes a new signing key per start and refuses to run next to prod. A
Flyway callback in db/local adds dev@localhost as PLATFORM_ADMIN with a
linked Steam ID.
2026-09-28 13:07:40 +02:00
lobo 32d4f40eec feat(hint): add admin hints for the dashboard
Build, Test & Push Auth Service / quality-check (push) Successful in 42s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
2026-09-28 10:07:49 +02:00
lobo 5341e0b870 feat(security): check role markers on services
Build, Test & Push Auth Service / quality-check (push) Successful in 41s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
Every service method a controller calls carries @RequiresRole or @Public, checked by a method interceptor and by ArchUnit. deleteByAdmin needs PLATFORM_ADMIN; the Steam callback's linkVerifiedSteamId stays unmarked.
2026-09-28 08:50:10 +02:00
lobo ff26760d40 fix(account): require the Core internal URL
Build, Test & Push Auth Service / quality-check (push) Successful in 40s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
The localhost default pointed Auth at itself in Docker, so the nightly cleanup failed silently at 04:15. Without a default a missing APP_CORE_INTERNAL_URL stops the start.
2026-09-28 07:46:21 +02:00
lobo 2ba0d99148 feat(admin): add roles, view as and the admin users API
Build, Test & Push Auth Service / quality-check (push) Successful in 39s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
users.role (USER < AUDITOR < PLATFORM_ADMIN) goes into the access token as the active role. POST /api/auth/view-as lowers the own login and is kept per refresh token, never above the stored role. /api/admin/users lists, blocks and deletes users; every action is written to admin_audit. A blocked login answers 403 ACCOUNT_BLOCKED, only after the right password.
2026-09-27 12:18:10 +02:00
lobo 7dc2fb613e feat(account): let users delete their account
Build, Test & Push Auth Service / quality-check (push) Successful in 32s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s
DELETE /api/users/me with the password sets users.deleted_at, ends all logins and frees the Steam ID. A nightly job removes users flagged 30 days ago, Core first, and prunes Core orphans. Dry run by default.
2026-09-27 11:46:22 +02:00
lobo a1763addce feat(version): answer the build commit and time
Build, Test & Push Auth Service / quality-check (push) Successful in 31s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
GET /api/version is public. CI passes GIT_SHA and BUILD_TIME as Docker build args; a local build answers dev.
2026-09-27 10:27:01 +02:00
lobo 4ec47f1b2c feat(profile): let users share stat groups
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s
Store the groups in user_settings.profile_stats and send them in the
profile ticket, so Core shows only these on the public profile.
2026-09-27 07:16:10 +02:00
lobo 4b2acef381 feat(steam): verify the Steam ID with Sign in through Steam
Build, Test & Push Auth Service / quality-check (push) Successful in 34s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
The Steam ID is no longer typed in at registration or in the settings.
A logged-in user links it through Steam OpenID 2.0: Auth checks the
answer with Steam (check_authentication) and stores the ID as verified
(V11 steam_id_verified). A one-time state plus a binding cookie tie the
callback to the browser that started the link, so a foreign Steam login
link cannot move someone's Steam ID into another account.

Typed-in IDs from before stay unverified; a verified link from another
account takes them away. DELETE /api/users/steam-link unlinks.
2026-09-26 11:20:04 +02:00
lobo e3433799f1 docs(readme): add the architecture overview 2026-09-26 07:37:44 +02:00
lobo 930e08e6dd feat(login): cookie session with rotating refresh token
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s
The login token moves out of localStorage into an HttpOnly cookie
(15 min), so a script on the page can no longer read it. A random
refresh token (30 days, sliding) renews it; only its SHA-256 is
stored. Each refresh replaces the token, and a replaced token that
comes back revokes every token of that login.

Unsafe requests need an X-TBP-Client or Authorization header, and
CORS allows only the exact frontend origin. A missing or expired
token now answers 401 instead of 403, so the frontend can refresh.
2026-09-25 13:45:04 +02:00
lobo 600b0b143f feat(login): publish verify key as JWKS
Build, Test & Push Auth Service / quality-check (push) Successful in 26s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s
Auth serves its Ed25519 public key at GET /.well-known/jwks.json, so
Core can find the key by the token kid. Startup fails on a blank
JWT_KID, because compose turns an unset value into "" and Core finds
keys only by kid.
2026-09-25 11:01:28 +02:00
lobo 3a48ce0ff1 feat(auth): rate limit registration, atomic login limit
10 failed registrations per IP block it for 15 minutes (429 TOO_MANY_ATTEMPTS with Retry-After). Login and registration now take an attempt before the check, so a burst of parallel requests cannot pass the limit.
2026-09-25 09:06:30 +02:00
361 changed files with 17784 additions and 1443 deletions

No files matched your search

+4
View File
@@ -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 }}
+9
View File
@@ -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"]
-22
View File
@@ -1,22 +0,0 @@
# docker-compose.dev.yml
services:
# ─────────────────────────────────────────────
# LOCAL DATABASE FOR DEVELOPMENT
# ─────────────────────────────────────────────
tbp-db-local:
image: postgres:17-alpine
container_name: tbp_db_local
restart: unless-stopped
ports:
# Map the container port 5432 to your local machine port 5432
- "5432:5432"
environment:
- POSTGRES_DB=tbp_auth
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
# Use a local volume to persist data across container restarts
- tbp_local_db_data:/var/lib/postgresql/data
volumes:
tbp_local_db_data:
+4 -5
View File
@@ -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>
+434 -33
View File
@@ -1,50 +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).
- 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 for key rotation.
- 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.
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends
every access token signed with the old key at once. The frontend renews them with the refresh cookie.
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
token that comes back revokes all tokens of that login. Logout revokes them too.
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
frontend origin. A `Bearer` header still works for API clients and tests.
- User settings: display name (unique, case-insensitive), language, dark mode, 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/auth/login` | public |
| `GET` | `/api/users/me` | JWT |
| `PUT` | `/api/users/settings` | JWT |
| `PUT` | `/api/users/settings/appearance` | JWT |
| `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 |
| `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
@@ -54,32 +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).
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:
@@ -89,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
@@ -99,11 +504,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap
- Rate limit `/api/auth/login` and `/api/registration/**`.
- JWKS endpoint (`/.well-known/jwks.json`), so Core fetches the public key and keys rotate without a redeploy.
- Short-lived access token plus rotating refresh token in an httpOnly cookie, with CSRF protection. Today the token
lives 24 h in `localStorage` and cannot be revoked.
- 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/");
}
}
@@ -0,0 +1,9 @@
package auth_service.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@@ -0,0 +1,41 @@
package auth_service.configuration.security;
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.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Set;
/**
* CSRF guard for the login cookies. A request that changes data must carry {@code X-TBP-Client} or
* {@code Authorization}. A page on another origin can send neither without a CORS preflight, and CORS allows only
* our own origins.
*/
public class ClientHeaderFilter extends OncePerRequestFilter {
public static final String CLIENT_HEADER = "X-TBP-Client";
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS");
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
if (SAFE_METHODS.contains(request.getMethod())
|| request.getHeader(CLIENT_HEADER) != null
|| request.getHeader("Authorization") != null) {
filterChain.doFilter(request, response);
return;
}
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":\"CLIENT_HEADER_MISSING\",\"message\":\"Missing X-TBP-Client header\"}");
}
}
@@ -1,28 +1,41 @@
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;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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. A blocked or deleted user, and a token
* issued before the last role change of its user, count as not logged in.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER = "Bearer ";
private final JwtService jwtService;
private final ActiveUserCheck activeUserCheck;
@Override
protected void doFilterInternal(
@@ -31,39 +44,35 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
filterChain.doFilter(request, response);
return;
}
try {
final String userEmail = jwtService.extractEmail(jwt);
final String userId = jwtService.extractUserId(jwt);
final Long steamId = jwtService.extractSteamId(jwt);
if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserPrincipal principal = new UserPrincipal(userId, userEmail, steamId);
Optional<UserPrincipal> principal = Stream.of(AuthCookies.read(request, AuthCookies.ACCESS_COOKIE), bearer(request))
.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,
principal.get(),
null,
Collections.emptyList()
authorities(principal.get().role())
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (Exception e) {
log.error("JWT Validation failed: {}", e.getMessage());
}
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();
}
}
@@ -1,8 +1,16 @@
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;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Jwks;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
@@ -11,46 +19,64 @@ 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;
import java.util.Map;
import java.util.Optional;
/**
* Service responsible for generating and validating JSON Web Tokens (JWT).
* 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 {
public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceTokenIssuer, PublicKeyPublisher {
// Token validity: 24 hours in milliseconds
private static final long JWT_EXPIRATION_MS = 86400000;
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;
private PrivateKey privateKey;
private PublicKey publicKey;
private JwtParser parser;
public JwtService(JwtConfig jwtConfig) {
public JwtService(JwtConfig jwtConfig, SessionProperties sessionProperties) {
this.jwtConfig = jwtConfig;
this.sessionProperties = sessionProperties;
}
@PostConstruct
void init() {
// Compose passes an unset JWT_KID as "", which hides the yml default. Core finds keys only by kid.
if (jwtConfig.getKid() == null || jwtConfig.getKid().isBlank()) {
throw new IllegalStateException("JWT_KID must not be blank");
}
this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
this.parser = Jwts.parser().verifyWith(publicKey).build();
}
@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() + JWT_EXPIRATION_MS));
.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);
@@ -62,59 +88,70 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer {
}
@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();
}
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
@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();
}
public String extractUserId(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("userId", String.class);
}
public Long extractSteamId(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("steamId", Long.class);
@Override
public List<Map<String, ?>> publicJwks() {
return List.of(Jwks.builder()
.key(publicKey)
.id(jwtConfig.getKid())
.publicKeyUse("sig")
.algorithm("EdDSA")
.build());
}
/**
* True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login.
* The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is
* signed with the same key, but is no login.
*/
public boolean isTokenValid(String token) {
public Optional<UserPrincipal> parseLoginToken(String token) {
try {
var claims = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get(TYPE_CLAIM) == null;
} catch (Exception _) {
return false;
Claims claims = parser.parseSignedClaims(token).getPayload();
if (!isKind(claims, LOGIN_AUDIENCE)) {
return Optional.empty();
}
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) {
@@ -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);
}
}
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -12,11 +13,14 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
@@ -37,7 +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
};
@@ -65,13 +72,14 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// Use OriginPatterns instead of AllowedOrigins to safely support "*" wildcards
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins));
// Exact origins only: with credentials, a pattern would send the login cookies to every matching site.
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowedHeaders(List.of("Content-Type", "Authorization", ClientHeaderFilter.CLIENT_HEADER));
// Lets the login page read how long a rate-limit block lasts
configuration.setExposedHeaders(List.of("Retry-After"));
configuration.setAllowCredentials(false); // Set to true later if we use cookies
configuration.setAllowCredentials(true);
configuration.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// Apply this CORS config to all endpoints
@@ -86,11 +94,18 @@ public class SecurityConfig {
// Tell Spring Security to use the CorsConfigurationSource bean defined above
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 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()
)
.addFilterAfter(new ClientHeaderFilter(), CorsFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
@@ -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");
}
}
@@ -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);
}
}
@@ -0,0 +1,26 @@
package auth_service.modules.login.api;
import auth_service.shared.security.PublicKeyPublisher;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class JwksController {
private final PublicKeyPublisher publicKeyPublisher;
@GetMapping(value = "/.well-known/jwks.json", produces = "application/json")
public ResponseEntity<Map<String, List<Map<String, ?>>>> jwks() {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(Map.of("keys", publicKeyPublisher.publicJwks()));
}
}
@@ -1,21 +1,35 @@
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;
import auth_service.modules.login.service.LoginService;
import auth_service.modules.login.service.RefreshTokenService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.api.ClientIp;
import auth_service.shared.api.dto.response.ErrorResponse;
import io.micrometer.core.annotation.Timed;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional;
/**
* API handling authentication requests.
* API handling authentication requests. The tokens travel only in HttpOnly cookies, never in a body.
*/
@RestController
@RequestMapping("/api/auth")
@@ -23,6 +37,8 @@ import org.springframework.web.bind.annotation.RestController;
public class LoginController {
private final LoginService loginService;
private final RefreshTokenService refreshTokenService;
private final AuthCookies authCookies;
/**
* Authenticates a user based on email and password.
@@ -32,23 +48,61 @@ public class LoginController {
*/
@PostMapping("/login")
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest,
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String previousRefreshToken) {
var user = ApiMapper.toModel(request);
var token = loginService.login(user, clientIp(httpRequest));
return ResponseEntity.ok(new LoginResponse("Login successful", token));
Session session = loginService.login(user, ClientIp.of(httpRequest), previousRefreshToken);
return ResponseEntity.ok()
.headers(cookies(sessionCookies(session)))
.body(new LoginResponse("Login successful"));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
Optional<Session> session = refreshTokenService.refresh(refreshToken);
if (session.isEmpty()) {
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"));
}
/**
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
* from the client and can be faked, so only the last one counts.
* "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.
*/
private static String clientIp(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor == null || forwardedFor.isBlank()) {
return request.getRemoteAddr();
@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();
}
String[] hops = forwardedFor.split(",");
return hops[hops.length - 1].trim();
return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build();
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
refreshTokenService.revoke(refreshToken);
return ResponseEntity.noContent().headers(cookies(authCookies.cleared())).build();
}
private List<ResponseCookie> sessionCookies(Session session) {
return List.of(authCookies.access(session.accessToken()), authCookies.refresh(session.refreshToken()));
}
private static HttpHeaders cookies(List<ResponseCookie> cookies) {
HttpHeaders headers = new HttpHeaders();
cookies.forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString()));
return headers;
}
}
@@ -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) {
}
@@ -1,8 +1,7 @@
package auth_service.modules.login.api.dto.response;
/**
* DTO representing the outgoing login response.
* The email will be null if the login fails.
* DTO representing the outgoing login response. The tokens are in the Set-Cookie headers.
*/
public record LoginResponse(String message, String token) {
public record LoginResponse(String message) {
}
@@ -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");
}
}
@@ -0,0 +1,13 @@
package auth_service.modules.login.model;
import auth_service.shared.security.Role;
import java.time.Instant;
import java.util.UUID;
/**
* @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) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.login.model;
public record Session(String accessToken, String refreshToken) {
}
@@ -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,11 +10,19 @@ 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
*/
Optional<User> findByEmail(String email);
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);
}
@@ -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,34 +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);
return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst();
}
// Build the query: SELECT u FROM UserEntity u WHERE u.email = :email
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("email"), email));
@Override
public Optional<User> findById(long userId) {
return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst();
}
TypedQuery<MinimalUserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
return typedQuery.getResultStream()
.findFirst()
.map(EntityMapper::mapToModel);
@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);
}
}
@@ -0,0 +1,32 @@
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;
import java.util.UUID;
public interface RefreshTokenRepository {
/**
* @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.
*/
Optional<RefreshToken> findByHashForUpdate(String tokenHash);
void markRotated(long id, Instant rotatedAt);
void revokeFamily(UUID familyId, Instant revokedAt);
int deleteExpired(Instant now);
}
@@ -0,0 +1,75 @@
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;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Repository
public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
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(
"SELECT t FROM RefreshTokenEntity t WHERE t.tokenHash = :hash", RefreshTokenEntity.class)
.setParameter("hash", tokenHash)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.getResultList()
.stream()
.findFirst()
.map(EntityMapper::mapToModel);
}
@Override
public void markRotated(long id, Instant rotatedAt) {
entityManager.find(RefreshTokenEntity.class, id).setRotatedAt(rotatedAt);
}
@Override
public void revokeFamily(UUID familyId, Instant revokedAt) {
entityManager.createQuery("UPDATE RefreshTokenEntity t SET t.revokedAt = :now "
+ "WHERE t.familyId = :family AND t.revokedAt IS NULL")
.setParameter("now", revokedAt)
.setParameter("family", familyId)
.executeUpdate();
}
@Override
public int deleteExpired(Instant now) {
return entityManager.createQuery("DELETE FROM RefreshTokenEntity t WHERE t.expiresAt < :now")
.setParameter("now", now)
.executeUpdate();
}
}
@@ -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;
}
Loaded 100 of 361 files, more files were not shown because too many files have changed in this diff. Show more