Compare commits

...
9 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
82 changed files with 2334 additions and 168 deletions

No files matched your search

-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:
-5
View File
@@ -86,11 +86,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- testing --> <!-- testing -->
<dependency> <dependency>
+66 -31
View File
@@ -1,15 +1,17 @@
# To Be Played – Auth Service # To Be Played – Auth Service
Identity service of **To Be Played**, a Steam backlog tracker. It handles registration, login, user settings and the Identity service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
public profile, and it signs the JWTs that the Core service trusts. by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It handles registration, login, user
settings and the public profile, and it signs the JWTs that the Core service trusts.
To Be Played is split into three repositories: To Be Played is split into four repositories:
| Repository | Role | | Repository | Role |
|---|---| |---|---|
| **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs | | **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs | | ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app | | ToBePlayed-Frontend | React single-page app |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
## Features ## Features
@@ -25,14 +27,16 @@ To Be Played is split into three repositories:
wrong password, and every failed login gets the same error. wrong password, and every failed login gets the same error.
- JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify - JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify
tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the
public key as a JWK, so Core finds the right key after a rotation. public key as a JWK. Auth publishes only the current key; Core finds it by `kid`. A rotation (new `JWT_KID`) ends
every access token signed with the old key at once. The frontend renews them with the refresh cookie.
- Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in - Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in
`tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in `tbp_access` (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in
`tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced `tbp_refresh` (sent to `/api/auth` only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced
token that comes back revokes all tokens of that login. Logout revokes them too. token that comes back revokes all tokens of that login. Logout revokes them too.
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the - CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
frontend origin. A `Bearer` header still works for API clients and tests. frontend origin. A `Bearer` header still works for API clients and tests.
- User settings: display name (unique, case-insensitive), language, dark mode. - User settings: display name (unique, case-insensitive), language, dark mode, and the chart form per stat group
(`stat_charts`, V31): bars, pie, donut, line or list. `StatCharts` holds the allowed forms per group.
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam - Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
@@ -60,6 +64,14 @@ To Be Played is split into three repositories:
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next `refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
refresh. Core checks the same claim. refresh. Core checks the same claim.
**Role change**: an admin sets the role of another user on the admin users page (`PUT /api/admin/users/{id}/role`).
The change is a row in `role_change` (actor, old and new role, time; shown in the "Roles" dialog) and in
`admin_audit` (`ROLE_CHANGE`), sets `users.role_changed_at` and clears "view as" of the open logins. An access
token issued before `role_changed_at` counts as not logged in (`401`) at Auth and at Core, within 30 s (cache
TTL); the frontend then refreshes and repeats the request, and the refresh reads the stored role. A role set by
SQL (the first admin) sets no `role_changed_at`; it works from the next refresh. There is never 0 admins: every
write that can remove an admin first locks the active admins (`shared/account/ActiveAdmins`, `SELECT ... FOR
UPDATE`), the actor must still be one of them, and the last admin cannot delete the own account.
- **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or - **Role markers on services**: every service method a controller calls carries `@RequiresRole(Role.X)` or
`@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the `@Public`, on the method or on its class (the method wins). A Spring Security method interceptor checks the
active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked); active role while a request is served (scheduled jobs and `@Async` work run without a user and are not checked);
@@ -68,8 +80,9 @@ To Be Played is split into three repositories:
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
service method calling another service's method that needs more. service method calling another service's method that needs more.
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock - **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token (`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth and at Core; the login then
until it expires, max 15 min; the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as
"Delete my account"). No action on the own account
or on another admin. Every action and every "view as" is a row in `admin_audit`. or on another admin. Every action and every "view as" is a row in `admin_audit`.
Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the
right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user
@@ -132,6 +145,8 @@ To Be Played is split into three repositories:
| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN | | `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` UNCONFIRMED, PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN | | `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN | | `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`, also for an unconfirmed request) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/role` (`{role, expectedRole}`; `204`; `400` for an unknown role; `404 USER_NOT_FOUND`; `409 SELF_ACTION`, `ACTOR_NOT_ADMIN`, `ROLE_CHANGED` (the role is not `expectedRole`), `ALREADY_DELETED`, `TARGET_IS_BLOCKED`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `GET` | `/api/admin/users/{id}/role-changes` (`{results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}`, newest first, 50 at most; `actorEmail` null after the actor is purged) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN | | `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`, `TARGET_IS_UNCONFIRMED`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/users/{id}/unconfirmed` (hard delete of an unconfirmed request; `204`; `404`; `409 NOT_UNCONFIRMED`, `SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
@@ -150,7 +165,7 @@ To Be Played is split into three repositories:
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN | | `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN | | `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN | | `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `409 LAST_ADMIN` (the only active admin), `429`) | access token |
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
| `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/appearance` | access token |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token | | `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
@@ -163,19 +178,21 @@ To Be Played is split into three repositories:
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token | | `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token | | `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/public/profiles/{displayName}` | public |
| `PUT` | `/api/users/settings/stat-chart` (`{group, chart}`; `chart` null = back to the default; `204`; `400 VALIDATION_FAILED` for an unknown group or form) | access token |
| `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token |
| `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
| `GET` | `/.well-known/jwks.json` (the current public key as a JWK, `Cache-Control: max-age=300`) | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
Endpoints use Spring's API versioning with a query parameter: `?version=1.0` (default `1.0`).
## Tech stack ## Tech stack
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads - Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
- PostgreSQL 17, Flyway migrations - PostgreSQL 17, Flyway migrations
- JJWT with Ed25519 keys - JJWT with Ed25519 keys
- Spring Boot Actuator + Micrometer (Prometheus) - Spring Boot Actuator (`/actuator/health` only)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit - Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image - CI: Gitea Actions (`.gitea/workflows/publish-auth.yml`), multi-stage Docker image
## Architecture ## Architecture
@@ -194,9 +211,9 @@ may log in and is shown to others" uses `shared/account/ActiveUser`.
A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the
display name and the settings. A new setting needs no new repository method. display name and the settings. A new setting needs no new repository method.
Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write, Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
`activeUsers` (the filter's "blocked or deleted?" check per request) after 30 seconds. Every write evicts what it `activeUsers` (the filter's "blocked or deleted, and when did the role change?" check per request) after 30 seconds.
changes. A request that read the database just before a block or delete can put the old answer back, so the short Every write evicts what it changes. A request that read the database just before a block or delete can put the old answer back, so the short
TTL is the real limit: a blocked or deleted user keeps a valid access token for up to 30 s. TTL is the real limit: a blocked or deleted user, or a token from before a role change, works for up to 30 s.
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package. `profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image. `version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core `account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
@@ -237,8 +254,8 @@ sequenceDiagram
B->>A: POST /api/auth/login B->>A: POST /api/auth/login
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth) A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
B->>C: API call with tbp_access B->>C: API call with tbp_access
C->>A: GET /.well-known/jwks.json (new kid only) C->>A: GET /.well-known/jwks.json (unknown kid only)
A-->>C: Public keys (Ed25519) A-->>C: Current public key (Ed25519, one JWK)
C-->>B: 200, or 401 when the access token expired C-->>B: 200, or 401 when the access token expired
B->>A: POST /api/auth/refresh with tbp_refresh B->>A: POST /api/auth/refresh with tbp_refresh
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
@@ -250,8 +267,8 @@ sequenceDiagram
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script 1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
can read them. can read them.
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them 2. Core verifies the access token with Auth's public keys only. `JwksController` publishes the current key only; Core
again only for an unknown `kid`. loads it again only for an unknown `kid`.
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again. 3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login. `RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
@@ -319,7 +336,7 @@ sequenceDiagram
A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free) A->>M: up to 20 due rows (account mails first, at most APP_MAIL_DAILY_LIMIT per 24 h, broadcast rows keep 10 free)
M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min
end end
Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days Note over A: after 10 tries FAILED and a warning in the log, SENT rows are removed after 30 days
``` ```
### Registration flow ### Registration flow
@@ -383,16 +400,21 @@ sequenceDiagram
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
(`AccountStatePublisher`); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer (`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
nor a database connection waits for Core; a failed call only warns. cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display
name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name:
`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket:
`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile
hides (#27). The call runs on the task executor, so neither the answer nor a database
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant A as Auth participant A as Auth
participant C as Core participant C as Core
A->>A: link / unlink / block / unblock / name change (commit) A->>A: link / unlink / block / unblock / name change (commit)
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked} A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
C->>C: blocked_user, Steam ID in user_steam_sync, display name in account_name C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name
Note over A,C: nightly 04:15 Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/states (every user) A->>C: PUT /internal/accounts/states (every user)
``` ```
@@ -411,7 +433,7 @@ other, then start them in this order (Core also starts without Auth and finds it
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` 3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development (`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`. values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8 4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
@@ -431,18 +453,24 @@ Needs Java 25, Docker and your own keys.
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint). service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core Auth has no `application-dev.yml`: any profile but `local` (also none) runs the same way and takes the keys from the
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no environment. `local` must not be combined with `prod`.
cookie settings. Open the frontend on `localhost` (not `127.0.0.1`), so the browser sends the cookies to every
local port. Only `tbp_access` gets the domain from `APP_AUTH_COOKIE_DOMAIN` (blank = host-only; production sets the parent
domain, so Core gets it too). `tbp_refresh` (`Path=/api/auth`) and `tbp_steam_link` are always host-only. The cookies
are `Secure` (`app.auth.cookie-secure`, default `true`). Chrome and Firefox accept `Secure` cookies on
`http://localhost`; Safari drops them, so the `local` profile sets `cookie-secure: false`. With your own keys on Safari
set `APP_AUTH_COOKIE_SECURE=false`. Open the frontend on `localhost`, not `127.0.0.1`.
Configuration comes from environment variables: Configuration comes from environment variables:
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) | | `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) |
| `JWT_KID` | Key id written into the token header | | `JWT_KID` | Key id in the token header (default `tbp-auth-1`); Auth refuses to start when it is blank |
| `DB_PASSWORD` | Local PostgreSQL password | | `APP_AUTH_COOKIE_DOMAIN` | Domain of `tbp_access`, blank = host-only |
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the nightly cleanup deletes nothing (it still sends the account states to Core); the 7-day purge of unconfirmed registrations runs anyway |
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | | `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) | | `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) | | `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
@@ -459,6 +487,13 @@ openssl pkey -in private.pem -outform DER | base64 -w0 # JWT_PRIVATE_KEY
openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY openssl pkey -in private.pem -pubout -outform DER | base64 -w0 # JWT_PUBLIC_KEY
``` ```
### Request timing log
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
duration, e.g. `GET /api/users/me 200 12 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
that leaves the filter chain is logged as `500`.
## Tests ## Tests
```bash ```bash
@@ -15,7 +15,8 @@ public class CacheConfig {
// After write, not after access: an entry another path changed without an evict still ages out. // After write, not after access: an entry another path changed without an evict still ages out.
static final Duration DEFAULT_TTL = Duration.ofMinutes(10); static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
// The real limit after a block or delete: a concurrent read can put the old answer back after the evict. // The real limit after a block, delete or role change: a concurrent read can put the old answer back after the
// evict.
static final Duration ACTIVE_USERS_TTL = Duration.ofSeconds(30); static final Duration ACTIVE_USERS_TTL = Duration.ofSeconds(30);
@Bean @Bean
@@ -0,0 +1,54 @@
package auth_service.configuration;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerMapping;
import java.io.IOException;
/**
* One INFO line per request: method, handler pattern, status, duration. The pattern (e.g. {@code /api/games/{appid}})
* stands for the path, so the line holds no ids, query, user or credentials. A request no handler took shows {@code -}.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestTimingFilter extends OncePerRequestFilter {
private static final Logger LOG = LoggerFactory.getLogger(RequestTimingFilter.class);
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
long start = System.nanoTime();
int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
try {
filterChain.doFilter(request, response);
status = response.getStatus();
} finally {
// An exception that leaves the chain ends as a 500; the container sets it only after this filter.
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
LOG.info("{} {} {} {} ms", request.getMethod(), pattern != null ? pattern : "-", status,
(System.nanoTime() - start) / 1_000_000);
}
}
/**
* The health check calls the actuator every 30 s; its lines would flood the log.
*/
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
return request.getRequestURI().startsWith("/actuator/");
}
}
@@ -25,8 +25,8 @@ import java.util.stream.Stream;
/** /**
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that * Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
* verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user counts as * verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user, and a token
* not logged in. * issued before the last role change of its user, count as not logged in.
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -48,7 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
.flatMap(Optional::stream) .flatMap(Optional::stream)
.map(jwtService::parseLoginToken) .map(jwtService::parseLoginToken)
.flatMap(Optional::stream) .flatMap(Optional::stream)
.filter(each -> activeUserCheck.isActive(each.getIdAsLong())) .filter(each -> activeUserCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
.findFirst(); .findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -19,6 +19,7 @@ import java.security.PrivateKey;
import java.security.PublicKey; import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
@@ -65,15 +66,17 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
} }
@Override @Override
public String generateToken(String userId, Long steamId, Role role) { public String generateToken(String userId, Long steamId, Role role, Instant notBefore) {
long now = System.currentTimeMillis();
long issuedAt = notBefore == null ? now : Math.max(now, notBefore.toEpochMilli());
var builder = Jwts.builder() var builder = Jwts.builder()
.header().keyId(jwtConfig.getKid()).and() .header().keyId(jwtConfig.getKid()).and()
.issuer(ISSUER) .issuer(ISSUER)
.audience().add(LOGIN_AUDIENCE).and() .audience().add(LOGIN_AUDIENCE).and()
.subject(userId) .subject(userId)
.claim(ROLE_CLAIM, role.name()) .claim(ROLE_CLAIM, role.name())
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(issuedAt))
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis())); .expiration(new Date(now + sessionProperties.getAccessTokenTtl().toMillis()));
if (steamId != null) { if (steamId != null) {
builder.claim("steamId", steamId); builder.claim("steamId", steamId);
@@ -139,7 +142,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class),
Role.parse(claims.get(ROLE_CLAIM, String.class)))); Role.parse(claims.get(ROLE_CLAIM, String.class)),
claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant()));
} catch (Exception _) { } catch (Exception _) {
return Optional.empty(); return Optional.empty();
} }
@@ -1,6 +1,7 @@
package auth_service.modules.account.api; package auth_service.modules.account.api;
import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.PasswordResetInvalidException; import auth_service.modules.account.exception.PasswordResetInvalidException;
import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.shared.api.dto.response.ErrorResponse;
@@ -29,6 +30,12 @@ public class AccountExceptionController {
.body(new ErrorResponse("RESET_INVALID", ex.getMessage())); .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) @ExceptionHandler(TooManyAttemptsException.class)
public ResponseEntity<ErrorResponse> clientError(TooManyAttemptsException ex) { public ResponseEntity<ErrorResponse> clientError(TooManyAttemptsException ex) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
@@ -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");
}
}
@@ -1,7 +1,17 @@
package auth_service.modules.account.model; package auth_service.modules.account.model;
import java.time.Instant;
import java.util.List;
/** /**
* One user as Core copies it. * One user as Core copies it.
*
* @param roleChangedAt null when the role never changed on the admin page
* @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser}
* @param publicProfile may be followed in Core, see {@link auth_service.shared.account.PublicUser}
* @param profileSections the profile parts the user shows ({@link auth_service.shared.account.ProfileSections}),
* null without a settings row
*/ */
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked) { public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
boolean listed, boolean publicProfile, List<String> profileSections) {
} }
@@ -1,8 +1,12 @@
package auth_service.modules.account.repo; package auth_service.modules.account.repo;
import auth_service.modules.account.model.CoreAccountState; import auth_service.modules.account.model.CoreAccountState;
import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.account.ActiveUser; import auth_service.shared.account.ActiveUser;
import auth_service.shared.account.EmailAddress; import auth_service.shared.account.EmailAddress;
import auth_service.shared.account.ListedUser;
import auth_service.shared.account.ProfileSections;
import auth_service.shared.account.PublicUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@@ -10,9 +14,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.sql.Array;
import java.sql.SQLException;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -111,17 +118,41 @@ public class AccountRepository {
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) -> private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"), new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
!rs.getBoolean("enabled")); !rs.getBoolean("enabled"),
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(),
rs.getBoolean("listed"), rs.getBoolean("public_profile"), sections(rs.getArray("profile_tiles")));
// Stored as "ID:SIZE", see PublicProfileRepository.tile.
private static List<String> sections(Array storedTiles) throws SQLException {
if (storedTiles == null) {
return null;
}
return ProfileSections.of(Arrays.stream((String[]) storedTiles.getArray())
.map(stored -> stored.replaceFirst(":[^:]*$", ""))
.toList());
}
private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, "
+ "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed, "
+ "COALESCE(" + PublicUser.of("u", "s") + ", FALSE) AS public_profile, s.profile_tiles "
+ "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id";
public Optional<CoreAccountState> findState(long userId) { public Optional<CoreAccountState> findState(long userId) {
return jdbc.query("SELECT id, steam_id, display_name, enabled FROM users WHERE id = ?", STATE_ROW, userId).stream().findFirst(); return jdbc.query(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst();
} }
/** /**
* Every user, also the flagged ones. * Every user, also the flagged ones.
*/ */
public List<CoreAccountState> findAllStates() { public List<CoreAccountState> findAllStates() {
return jdbc.query("SELECT id, steam_id, display_name, enabled FROM users ORDER BY id", STATE_ROW); return jdbc.query(STATE_SELECT + " ORDER BY u.id", STATE_ROW);
}
/**
* Locks the active admins until the transaction ends, see {@link ActiveAdmins}.
*/
public List<Long> lockActiveAdmins() {
return jdbc.queryForList(ActiveAdmins.LOCK, Long.class);
} }
/** /**
@@ -1,9 +1,11 @@
package auth_service.modules.account.service; package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.AttemptLimiter; import auth_service.shared.security.AttemptLimiter;
import auth_service.shared.security.OpaqueToken; import auth_service.shared.security.OpaqueToken;
@@ -37,18 +39,20 @@ public class AccountService implements AccountDeleter {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final AccountMailer accountMailer; private final AccountMailer accountMailer;
private final AccountStatePublisher accountStatePublisher;
private final TransactionOperations transactions; private final TransactionOperations transactions;
private final Duration retention; private final Duration retention;
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
CoreAccountService coreAccountService, AccountMailer accountMailer, CoreAccountService coreAccountService, AccountMailer accountMailer,
TransactionOperations transactions, AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
@Value("${app.account-cleanup.retention}") Duration retention) { @Value("${app.account-cleanup.retention}") Duration retention) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.accountMailer = accountMailer; this.accountMailer = accountMailer;
this.accountStatePublisher = accountStatePublisher;
this.transactions = transactions; this.transactions = transactions;
this.retention = retention; this.retention = retention;
} }
@@ -116,6 +120,9 @@ public class AccountService implements AccountDeleter {
private boolean flagAndTellCore(long userId) { private boolean flagAndTellCore(long userId) {
Instant now = Instant.now(); Instant now = Instant.now();
boolean flagged = Boolean.TRUE.equals(transactions.execute(status -> { boolean flagged = Boolean.TRUE.equals(transactions.execute(status -> {
if (accountRepository.lockActiveAdmins().equals(List.of(userId))) {
throw new LastAdminException();
}
if (accountRepository.flagDeleted(userId, now) == 0) { if (accountRepository.flagDeleted(userId, now) == 0) {
return false; return false;
} }
@@ -129,8 +136,10 @@ public class AccountService implements AccountDeleter {
try { try {
coreAccountService.markDeleted(List.of(userId)); coreAccountService.markDeleted(List.of(userId));
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId, log.warn("Core did not take the delete of user {}, the state push tries again: {}", userId,
e.getMessage()); e.getMessage());
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
accountStatePublisher.publish(userId);
} }
return true; return true;
} }
@@ -4,30 +4,46 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.account.AccountStatePublisher;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
/** /**
* Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core * Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core
* (up to 13 s) would keep it from the pool. * (up to 13 s) would keep it from the pool. A failed send is tried again later; the scheduler thread only hands the
* send back to the executor. Retries live in memory, so the nightly cleanup stays the fallback.
*/ */
@Service @Service
@Slf4j @Slf4j
public class CoreAccountStatePublisher implements AccountStatePublisher { public class CoreAccountStatePublisher implements AccountStatePublisher {
static final List<Duration> RETRY_DELAYS = List.of(Duration.ofSeconds(10), Duration.ofMinutes(1),
Duration.ofMinutes(5));
private static final int LOCK_STRIPES = 64;
private final Object[] sendLocks = new Object[LOCK_STRIPES];
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final Executor executor; private final Executor executor;
private final TaskScheduler scheduler;
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService, public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
@Qualifier("applicationTaskExecutor") Executor executor) { @Qualifier("applicationTaskExecutor") Executor executor,
@Qualifier("taskScheduler") TaskScheduler scheduler) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.executor = executor; this.executor = executor;
this.scheduler = scheduler;
for (int i = 0; i < LOCK_STRIPES; i++) {
sendLocks[i] = new Object();
}
} }
@Override @Override
@@ -36,20 +52,30 @@ public class CoreAccountStatePublisher implements AccountStatePublisher {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override @Override
public void afterCommit() { public void afterCommit() {
executor.execute(() -> send(userId)); executor.execute(() -> send(userId, 0));
} }
}); });
} else { } else {
executor.execute(() -> send(userId)); executor.execute(() -> send(userId, 0));
} }
} }
private void send(long userId) { private void send(long userId, int attempt) {
try { try {
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state))); // Read and send under one lock per user: the send that runs last carries the newest state.
synchronized (sendLocks[(int) (userId % LOCK_STRIPES)]) {
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
}
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId, if (attempt < RETRY_DELAYS.size()) {
e.getMessage()); log.warn("Core did not take the state of user {} (try {}), trying again later: {}", userId,
attempt + 1, e.getMessage());
scheduler.schedule(() -> executor.execute(() -> send(userId, attempt + 1)),
Instant.now().plus(RETRY_DELAYS.get(attempt)));
} else {
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
e.getMessage());
}
} }
} }
} }
@@ -2,12 +2,17 @@ package auth_service.modules.admin.api;
import auth_service.modules.admin.api.dto.request.BlockRequest; import auth_service.modules.admin.api.dto.request.BlockRequest;
import auth_service.modules.admin.api.dto.request.DecisionRequest; import auth_service.modules.admin.api.dto.request.DecisionRequest;
import auth_service.modules.admin.api.dto.request.RoleChangeRequest;
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse; import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
import auth_service.modules.admin.api.dto.response.AdminUserResponse; import auth_service.modules.admin.api.dto.response.AdminUserResponse;
import auth_service.modules.admin.api.dto.response.PendingCountResponse; import auth_service.modules.admin.api.dto.response.PendingCountResponse;
import auth_service.modules.admin.api.dto.response.RoleChangeListResponse;
import auth_service.modules.admin.api.dto.response.RoleChangeResponse;
import auth_service.modules.admin.model.AdminUser; import auth_service.modules.admin.model.AdminUser;
import auth_service.modules.admin.model.AdminUserPage; import auth_service.modules.admin.model.AdminUserPage;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.service.AdminUserService; import auth_service.modules.admin.service.AdminUserService;
import auth_service.modules.admin.service.RoleChangeService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -29,6 +34,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AdminUserController { public class AdminUserController {
private final AdminUserService adminUserService; private final AdminUserService adminUserService;
private final RoleChangeService roleChangeService;
@GetMapping @GetMapping
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query, public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
@@ -59,6 +65,19 @@ public class AdminUserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping("/{userId}/role")
public ResponseEntity<Void> changeRole(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
@Valid @RequestBody RoleChangeRequest request) {
roleChangeService.changeRole(principal.getIdAsLong(), userId, request.expectedRole(), request.role());
return ResponseEntity.noContent().build();
}
@GetMapping("/{userId}/role-changes")
public ResponseEntity<RoleChangeListResponse> findRoleChanges(@PathVariable long userId) {
return ResponseEntity.ok(new RoleChangeListResponse(roleChangeService.findChanges(userId).stream()
.map(AdminUserController::toApi).toList()));
}
@DeleteMapping(value = "/{userId}") @DeleteMapping(value = "/{userId}")
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) { public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
adminUserService.delete(principal.getIdAsLong(), userId); adminUserService.delete(principal.getIdAsLong(), userId);
@@ -84,4 +103,9 @@ public class AdminUserController {
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(), user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
user.status()); user.status());
} }
private static RoleChangeResponse toApi(RoleChange change) {
return new RoleChangeResponse(change.actorUserId(), change.actorEmail(), change.oldRole().name(),
change.newRole().name(), change.changedAt());
}
} }
@@ -0,0 +1,7 @@
package auth_service.modules.admin.api.dto.request;
import auth_service.shared.security.Role;
import jakarta.validation.constraints.NotNull;
public record RoleChangeRequest(@NotNull Role role, @NotNull Role expectedRole) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.admin.api.dto.response;
import java.util.List;
public record RoleChangeListResponse(List<RoleChangeResponse> results) {
}
@@ -0,0 +1,10 @@
package auth_service.modules.admin.api.dto.response;
import java.time.Instant;
/**
* @param actorEmail null after the actor is purged
*/
public record RoleChangeResponse(long actorUserId, String actorEmail, String oldRole, String newRole,
Instant changedAt) {
}
@@ -5,7 +5,8 @@ import lombok.Getter;
@Getter @Getter
public class AdminActionRefusedException extends RuntimeException { public class AdminActionRefusedException extends RuntimeException {
/** /**
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED, NOT_UNCONFIRMED or NOT_ACTIVE * SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED,
* NOT_UNCONFIRMED, NOT_ACTIVE, ACTOR_NOT_ADMIN, TARGET_IS_BLOCKED or ROLE_CHANGED
*/ */
private final String code; private final String code;
@@ -0,0 +1,11 @@
package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
import java.time.Instant;
/**
* @param actorEmail null after the actor is purged
*/
public record RoleChange(long actorUserId, String actorEmail, Role oldRole, Role newRole, Instant changedAt) {
}
@@ -0,0 +1,12 @@
package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
import java.time.Instant;
/**
* @param deleted flagged as deleted, or denied
* @param roleChangedAt null when the role never changed on the admin page
*/
public record RoleTarget(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
}
@@ -0,0 +1,68 @@
package auth_service.modules.admin.repo;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.model.RoleTarget;
import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Plain SQL on users, because the user and login modules own the entities.
*/
@Repository
@RequiredArgsConstructor
public class RoleChangeRepository {
private final JdbcTemplate jdbc;
/**
* Locks the active admins until the transaction ends, see {@link ActiveAdmins}.
*/
public List<Long> lockActiveAdmins() {
return jdbc.queryForList(ActiveAdmins.LOCK, Long.class);
}
public Optional<RoleTarget> lockTarget(long userId) {
return jdbc.query("""
SELECT role, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status, role_changed_at
FROM users WHERE id = ? FOR UPDATE""",
(rs, row) -> new RoleTarget(Role.parse(rs.getString("role")), !rs.getBoolean("enabled"),
rs.getBoolean("deleted"), rs.getString("status"),
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant()),
userId).stream().findFirst();
}
/**
* Also clears "view as" of every open login, so the next refresh starts at the new role.
*/
public void updateRole(long userId, Role role, Instant changedAt) {
jdbc.update("UPDATE users SET role = ?, role_changed_at = ? WHERE id = ?", role.name(),
Timestamp.from(changedAt), userId);
jdbc.update("UPDATE refresh_tokens SET active_role = NULL WHERE user_id = ? AND revoked_at IS NULL", userId);
}
public void insertChange(long actorUserId, long targetUserId, Role oldRole, Role newRole, Instant changedAt) {
jdbc.update("""
INSERT INTO role_change (actor_user_id, target_user_id, old_role, new_role, changed_at)
VALUES (?, ?, ?, ?, ?)""", actorUserId, targetUserId, oldRole.name(), newRole.name(),
Timestamp.from(changedAt));
}
public List<RoleChange> findChanges(long targetUserId, int limit) {
return jdbc.query("""
SELECT c.actor_user_id, a.email, c.old_role, c.new_role, c.changed_at
FROM role_change c LEFT JOIN users a ON a.id = c.actor_user_id
WHERE c.target_user_id = ? ORDER BY c.changed_at DESC, c.id DESC LIMIT ?""",
(rs, row) -> new RoleChange(rs.getLong("actor_user_id"), rs.getString("email"),
Role.parse(rs.getString("old_role")), Role.parse(rs.getString("new_role")),
rs.getTimestamp("changed_at").toInstant()),
targetUserId, limit);
}
}
@@ -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");
}
}
}
@@ -5,6 +5,8 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.ToString; import lombok.ToString;
import java.time.Instant;
@Builder @Builder
@Data @Data
public class User { public class User {
@@ -18,6 +20,7 @@ public class User {
private boolean pending; private boolean pending;
@Builder.Default @Builder.Default
private Role role = Role.USER; private Role role = Role.USER;
private Instant roleChangedAt;
public boolean canLogin() { public boolean canLogin() {
return enabled && !deleted && !pending; return enabled && !deleted && !pending;
@@ -18,7 +18,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
private static final String SELECT_USER = private static final String SELECT_USER =
"SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status = 'PENDING' AS pending, " "SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status = 'PENDING' AS pending, "
+ "role FROM users "; + "role, role_changed_at FROM users ";
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder() private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
.id(rs.getLong("id")) .id(rs.getLong("id"))
@@ -29,6 +29,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository {
.deleted(rs.getBoolean("deleted")) .deleted(rs.getBoolean("deleted"))
.pending(rs.getBoolean("pending")) .pending(rs.getBoolean("pending"))
.role(Role.parse(rs.getString("role"))) .role(Role.parse(rs.getString("role")))
.roleChangedAt(rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant())
.build(); .build();
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -4,6 +4,7 @@ import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository; import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.security.ActiveUserCheck; import auth_service.shared.security.ActiveUserCheck;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import auth_service.shared.security.UserTokenState;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,7 +21,9 @@ public class ActiveUserService implements ActiveUserCheck {
@Override @Override
@Cacheable(value = "activeUsers", key = "#userId") @Cacheable(value = "activeUsers", key = "#userId")
public boolean isActive(long userId) { public UserTokenState state(long userId) {
return minimalUserRepository.findById(userId).filter(User::canLogin).isPresent(); return minimalUserRepository.findById(userId).filter(User::canLogin)
.map(user -> new UserTokenState(true, user.getRoleChangedAt()))
.orElse(UserTokenState.INACTIVE);
} }
} }
@@ -1,7 +1,6 @@
package auth_service.modules.login.service; package auth_service.modules.login.service;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.AccountBlockedException; import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.InvalidCredentialsException;
@@ -23,7 +22,6 @@ import java.util.UUID;
public class LoginService { public class LoginService {
private final MinimalUserRepository minimalUserRepository; private final MinimalUserRepository minimalUserRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final TokenGenerator tokenGenerator;
private final LoginAttemptService loginAttemptService; private final LoginAttemptService loginAttemptService;
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final CacheManager cacheManager; private final CacheManager cacheManager;
@@ -32,11 +30,10 @@ public class LoginService {
private final String dummyHash; private final String dummyHash;
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder, public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService, LoginAttemptService loginAttemptService, RefreshTokenService refreshTokenService,
RefreshTokenService refreshTokenService, CacheManager cacheManager) { CacheManager cacheManager) {
this.minimalUserRepository = minimalUserRepository; this.minimalUserRepository = minimalUserRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.tokenGenerator = tokenGenerator;
this.loginAttemptService = loginAttemptService; this.loginAttemptService = loginAttemptService;
this.refreshTokenService = refreshTokenService; this.refreshTokenService = refreshTokenService;
this.cacheManager = cacheManager; this.cacheManager = cacheManager;
@@ -82,7 +79,6 @@ public class LoginService {
users.evict(user.getId()); users.evict(user.getId());
} }
refreshTokenService.revoke(previousRefreshToken); refreshTokenService.revoke(previousRefreshToken);
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getSteamId(), user.getRole()); return refreshTokenService.startSession(user.getId(), hash);
return new Session(accessToken, refreshTokenService.startFamily(user.getId(), hash));
} }
} }
@@ -53,11 +53,16 @@ public class RefreshTokenService {
* @throws InvalidCredentialsException when the password is not the one checked * @throws InvalidCredentialsException when the password is not the one checked
*/ */
@Transactional @Transactional
public String startFamily(long userId, String passwordHash) { public Session startSession(long userId, String passwordHash) {
if (!userRepository.lockPasswordHash(userId).map(current -> current.equals(passwordHash)).orElse(false)) { if (!userRepository.lockPasswordHash(userId).map(current -> current.equals(passwordHash)).orElse(false)) {
throw new InvalidCredentialsException("Invalid email or password"); throw new InvalidCredentialsException("Invalid email or password");
} }
return issue(userId, UUID.randomUUID(), Instant.now(), null); // Read after the lock, so a role change or block that committed during the login is seen.
User current = userRepository.findById(userId).filter(User::canLogin)
.orElseThrow(() -> new InvalidCredentialsException("Invalid email or password"));
String accessToken = tokenGenerator.generateToken(String.valueOf(userId), current.getSteamId(),
current.getRole(), current.getRoleChangedAt());
return new Session(accessToken, issue(userId, UUID.randomUUID(), Instant.now(), null));
} }
/** /**
@@ -125,7 +130,8 @@ public class RefreshTokenService {
if (switching && stored != Role.USER) { if (switching && stored != Role.USER) {
adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name()); adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name());
} }
String accessToken = tokenGenerator.generateToken(String.valueOf(current.getId()), current.getSteamId(), active); String accessToken = tokenGenerator.generateToken(String.valueOf(current.getId()), current.getSteamId(), active,
current.getRoleChangedAt());
return Optional.of(new Session(accessToken, next)); return Optional.of(new Session(accessToken, next));
} }
@@ -2,16 +2,14 @@ package auth_service.modules.profile.service;
import auth_service.modules.profile.model.PublicProfile; import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.repo.PublicProfileRepository; import auth_service.modules.profile.repo.PublicProfileRepository;
import auth_service.shared.account.ProfileSections;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Service @Service
@Public @Public
@@ -20,8 +18,6 @@ public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {} public record TicketedProfile(PublicProfile profile, String ticket) {}
private static final String STATS_PREFIX = "STATS_";
private final PublicProfileRepository publicProfileRepository; private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer; private final ProfileTicketIssuer profileTicketIssuer;
@@ -33,15 +29,9 @@ public class PublicProfileService {
.map(profile -> new TicketedProfile(profile, ticket(profile))); .map(profile -> new TicketedProfile(profile, ticket(profile)));
} }
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
private String ticket(PublicProfile profile) { private String ticket(PublicProfile profile) {
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id) List<String> tileIds = profile.tiles().stream().map(PublicProfile.Tile::id).toList();
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX))); return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(),
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList(); ProfileSections.statGroups(tileIds), ProfileSections.of(tileIds));
List<String> sections = new ArrayList<>(byStats.get(false));
if (!statGroups.isEmpty()) {
sections.add("STATS");
}
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
} }
} }
@@ -0,0 +1,29 @@
package auth_service.modules.stats.api;
import auth_service.modules.stats.api.dto.PeopleDto;
import auth_service.modules.stats.api.mapper.ApiMapper;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.service.UserListService;
import auth_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class UserListController {
private final UserListService userListService;
@GetMapping("/users")
public ResponseEntity<PeopleDto> getUsers(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam UserCountKey key,
@RequestParam(defaultValue = "0") int page) {
return ResponseEntity.ok(ApiMapper.toApi(userListService.users(principal.getIdAsLong(), key, page)));
}
}
@@ -0,0 +1,14 @@
package auth_service.modules.stats.api.dto;
import java.util.List;
/**
* Who stands behind a number. Never an id, avatar or Steam ID (#26): a private user is only in privateCount, the
* viewer only in includesYou.
*/
public record PeopleDto(List<Person> listed, long listedTotal, long privateCount, boolean includesYou, int page,
int totalPages) {
public record Person(String displayName) {
}
}
@@ -1,12 +1,19 @@
package auth_service.modules.stats.api.mapper; package auth_service.modules.stats.api.mapper;
import auth_service.modules.stats.api.dto.PeopleDto;
import auth_service.modules.stats.api.dto.PublicStatsDto; import auth_service.modules.stats.api.dto.PublicStatsDto;
import auth_service.modules.stats.model.PeopleList;
import auth_service.modules.stats.model.PublicStats; import auth_service.modules.stats.model.PublicStats;
public abstract class ApiMapper { public abstract class ApiMapper {
private ApiMapper() { private ApiMapper() {
} }
public static PeopleDto toApi(PeopleList people) {
return new PeopleDto(people.listed().stream().map(PeopleDto.Person::new).toList(), people.listedTotal(),
people.privateCount(), people.includesYou(), people.page(), people.totalPages());
}
public static PublicStatsDto toApi(PublicStats stats) { public static PublicStatsDto toApi(PublicStats stats) {
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked()); return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
} }
@@ -0,0 +1,8 @@
package auth_service.modules.stats.model;
/**
* @param others the users of a set without the viewer
* @param listed those of them shown by name
*/
public record PeopleCounts(long others, long listed, boolean includesYou) {
}
@@ -0,0 +1,10 @@
package auth_service.modules.stats.model;
import java.util.List;
/**
* One page of a user list: names of the listed users only, the rest as a count.
*/
public record PeopleList(List<String> listed, long listedTotal, long privateCount, boolean includesYou, int page,
int totalPages) {
}
@@ -0,0 +1,9 @@
package auth_service.modules.stats.model;
/**
* The user numbers of the landing page; each has a list behind it.
*/
public enum UserCountKey {
TOTAL_USERS,
STEAM_LINKED
}
@@ -1,13 +1,27 @@
package auth_service.modules.stats.repo; package auth_service.modules.stats.repo;
import auth_service.modules.stats.model.PeopleCounts;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.shared.account.ActiveUser; import auth_service.shared.account.ActiveUser;
import auth_service.shared.account.ListedUser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class StatsRepository { public class StatsRepository {
// One WHERE per number: the count and its list read the same set.
private static final Map<UserCountKey, String> SETS = Map.of(
UserCountKey.TOTAL_USERS, ActiveUser.of("u"),
UserCountKey.STEAM_LINKED, ActiveUser.of("u") + " AND u.steam_id IS NOT NULL");
// LEFT JOIN: a user without a settings row is in the number, and private.
private static final String FROM = " FROM users u LEFT JOIN user_settings s ON s.user_id = u.id WHERE ";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@Autowired @Autowired
@@ -16,13 +30,32 @@ public class StatsRepository {
} }
public long countTotalUsers() { public long countTotalUsers() {
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class); return count(UserCountKey.TOTAL_USERS);
return v != null ? v : 0L;
} }
public long countUsersWithSteamLinked() { public long countUsersWithSteamLinked() {
Long v = jdbc.queryForObject( return count(UserCountKey.STEAM_LINKED);
"SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class); }
private long count(UserCountKey key) {
Long v = jdbc.queryForObject("SELECT COUNT(*)" + FROM + SETS.get(key), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public PeopleCounts countUsers(UserCountKey key, long viewerId) {
return jdbc.queryForObject("SELECT COUNT(*) FILTER (WHERE u.id <> ?) AS others, "
+ "COUNT(*) FILTER (WHERE u.id <> ? AND " + ListedUser.of("u", "s") + ") AS listed, "
+ "COALESCE(BOOL_OR(u.id = ?), FALSE) AS you" + FROM + SETS.get(key),
(rs, row) -> new PeopleCounts(rs.getLong("others"), rs.getLong("listed"), rs.getBoolean("you")),
viewerId, viewerId, viewerId);
}
/**
* Selects the display name and nothing else, so no id can reach a response.
*/
public List<String> listedUserNames(UserCountKey key, long viewerId, int offset, int limit) {
return jdbc.queryForList("SELECT u.display_name" + FROM + SETS.get(key) + " AND u.id <> ? AND "
+ ListedUser.of("u", "s") + " ORDER BY LOWER(u.display_name), u.id LIMIT ? OFFSET ?",
String.class, viewerId, limit, offset);
}
} }
@@ -0,0 +1,35 @@
package auth_service.modules.stats.service;
import auth_service.modules.stats.model.PeopleCounts;
import auth_service.modules.stats.model.PeopleList;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.repo.StatsRepository;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Not cached: an opt-out shows on the next load.
*/
@Service
@RequiredArgsConstructor
public class UserListService {
static final int PAGE_SIZE = 40;
static final int MAX_PAGE = 250;
private final StatsRepository statsRepository;
@RequiresRole(Role.USER)
public PeopleList users(long viewerId, UserCountKey key, int page) {
int clamped = Math.clamp(page, 0, MAX_PAGE);
PeopleCounts counts = statsRepository.countUsers(key, viewerId);
int totalPages = (int) ((counts.listed() + PAGE_SIZE - 1) / PAGE_SIZE);
List<String> names = statsRepository.listedUserNames(key, viewerId, clamped * PAGE_SIZE, PAGE_SIZE);
return new PeopleList(names, counts.listed(), counts.others() - counts.listed(), counts.includesYou(), clamped,
totalPages);
}
}
@@ -4,6 +4,7 @@ import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.DashboardTilesRequest; import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest; import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
import auth_service.modules.user.api.dto.request.OwnerListsRequest;
import auth_service.modules.user.api.dto.request.ProfileTilesRequest; import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest; import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.StatChartRequest; import auth_service.modules.user.api.dto.request.StatChartRequest;
@@ -72,6 +73,13 @@ public class UserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/settings/owner-lists")
public ResponseEntity<Void> updateOwnerLists(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody OwnerListsRequest request) {
this.userService.updateOwnerLists(principal.getIdAsLong(), request.enabled());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/welcome-seen") @PutMapping(value = "/settings/welcome-seen")
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
this.userService.markWelcomeSeen(principal.getIdAsLong()); this.userService.markWelcomeSeen(principal.getIdAsLong());
@@ -0,0 +1,6 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record OwnerListsRequest(@NotNull Boolean enabled) {
}
@@ -8,6 +8,7 @@ import java.util.Map;
@Builder @Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
boolean showInOwnerLists,
AppearanceResponse appearance, ProfileStyleResponse profileStyle, AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles, List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) { Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
@@ -42,6 +42,7 @@ public abstract class ApiMapper {
.useDarkTheme(user.getSettings().getTheme().equals("dark")) .useDarkTheme(user.getSettings().getTheme().equals("dark"))
.language(user.getSettings().getLanguage()) .language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile()) .publicProfile(user.getSettings().isPublicProfile())
.showInOwnerLists(user.getSettings().isShowInOwnerLists())
.appearance(toApi(user.getSettings().getAppearance())) .appearance(toApi(user.getSettings().getAppearance()))
.profileStyle(toApi(user.getSettings().getProfileStyle())) .profileStyle(toApi(user.getSettings().getProfileStyle()))
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null .dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
@@ -16,7 +16,10 @@ public final class StatCharts {
"TREND", Set.of("LINE", "BARS"), "TREND", Set.of("LINE", "BARS"),
"FINISHED", Set.of("BARS", "LINE", "LIST"), "FINISHED", Set.of("BARS", "LINE", "LIST"),
"TASTE", Set.of("BARS", "PIE", "DONUT", "LIST"), "TASTE", Set.of("BARS", "PIE", "DONUT", "LIST"),
"PLATFORMS", Set.of("BARS", "PIE", "DONUT", "LIST")); "PLATFORMS", Set.of("BARS", "PIE", "DONUT", "LIST"),
"TOP_GAMES", Set.of("LIST", "BARS", "PIE", "DONUT"),
"ACTIVITY", Set.of("BARS", "LINE"),
"ACTIVITY_GAMES", Set.of("LIST", "BARS", "PIE", "DONUT"));
private StatCharts() { private StatCharts() {
} }
@@ -13,6 +13,8 @@ public class UserSettings {
private String language; private String language;
private String theme; private String theme;
private boolean publicProfile; private boolean publicProfile;
/** Listed by name only together with publicProfile, see ListedUser. */
private boolean showInOwnerLists;
@Builder.Default @Builder.Default
private Appearance appearance = Appearance.DEFAULT; private Appearance appearance = Appearance.DEFAULT;
@Builder.Default @Builder.Default
@@ -32,6 +32,9 @@ public class UserSettingsEntity {
@Column(name = "public_profile", nullable = false) @Column(name = "public_profile", nullable = false)
private boolean publicProfile; private boolean publicProfile;
@Column(name = "show_in_owner_lists", nullable = false)
private boolean showInOwnerLists;
@Column(nullable = false) @Column(nullable = false)
private String accent; private String accent;
@@ -28,6 +28,7 @@ public abstract class EntityMapper {
.language(settings.getLanguage()) .language(settings.getLanguage())
.theme(settings.getTheme()) .theme(settings.getTheme())
.publicProfile(settings.isPublicProfile()) .publicProfile(settings.isPublicProfile())
.showInOwnerLists(settings.isShowInOwnerLists())
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(), .appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(), settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(),
settings.isReduceMotion())) settings.isReduceMotion()))
@@ -61,6 +62,7 @@ public abstract class EntityMapper {
target.setLanguage(settings.getLanguage()); target.setLanguage(settings.getLanguage());
target.setTheme(settings.getTheme()); target.setTheme(settings.getTheme());
target.setPublicProfile(settings.isPublicProfile()); target.setPublicProfile(settings.isPublicProfile());
target.setShowInOwnerLists(settings.isShowInOwnerLists());
Appearance appearance = settings.getAppearance(); Appearance appearance = settings.getAppearance();
target.setAccent(appearance.accent()); target.setAccent(appearance.accent());
target.setCorners(appearance.corners()); target.setCorners(appearance.corners());
@@ -26,6 +26,7 @@ import java.time.Instant;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
@@ -53,15 +54,18 @@ public class UserService {
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
throw new DisplayNameAlreadyInUseException("The display name is already in use."); throw new DisplayNameAlreadyInUseException("The display name is already in use.");
} }
boolean renamed = displayName != null && userRepository.findById(userId) // The listing in Core depends on the name and the public profile.
.map(user -> !displayName.equals(user.getDisplayName())).orElse(true); boolean listingChanged = userRepository.findById(userId)
.map(user -> !Objects.equals(displayName, user.getDisplayName())
|| user.getSettings().isPublicProfile() != userSettings.isPublicProfile())
.orElse(displayName != null || userSettings.isPublicProfile());
userRepository.update(userId, user -> { userRepository.update(userId, user -> {
user.setDisplayName(displayName); user.setDisplayName(displayName);
user.getSettings().setLanguage(userSettings.getLanguage()); user.getSettings().setLanguage(userSettings.getLanguage());
user.getSettings().setTheme(userSettings.getTheme()); user.getSettings().setTheme(userSettings.getTheme());
user.getSettings().setPublicProfile(userSettings.isPublicProfile()); user.getSettings().setPublicProfile(userSettings.isPublicProfile());
}); });
if (renamed) { if (listingChanged) {
accountStatePublisher.publish(userId); accountStatePublisher.publish(userId);
} }
log.info("User settings updated for user {}", userId); log.info("User settings updated for user {}", userId);
@@ -126,6 +130,7 @@ public class UserService {
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateProfileTiles(long userId, List<Tile> tiles) { public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles))); userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
accountStatePublisher.publish(userId);
} }
/** /**
@@ -154,6 +159,14 @@ public class UserService {
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip())); user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
} }
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
public void updateOwnerLists(long userId, boolean enabled) {
userRepository.update(userId, user -> user.getSettings().setShowInOwnerLists(enabled));
accountStatePublisher.publish(userId);
}
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
@@ -1,8 +1,8 @@
package auth_service.shared.account; package auth_service.shared.account;
/** /**
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure only warns, * Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure is retried 3
* because the nightly cleanup sends every user again. * times, then the nightly cleanup sends every user again.
*/ */
public interface AccountStatePublisher { public interface AccountStatePublisher {
void publish(long userId); void publish(long userId);
@@ -0,0 +1,14 @@
package auth_service.shared.account;
/**
* Every write that can remove an admin runs {@link #LOCK} first in its transaction, so two of them never both see
* a second admin who is about to go.
*/
public final class ActiveAdmins {
public static final String LOCK = "SELECT u.id FROM users u WHERE u.role = 'PLATFORM_ADMIN' AND "
+ ActiveUser.of("u") + " ORDER BY u.id FOR UPDATE";
private ActiveAdmins() {
}
}
@@ -0,0 +1,15 @@
package auth_service.shared.account;
/**
* The only rule that decides who is shown by name in owner and user lists (#26). Everyone else is private. The
* settings alias may come from a LEFT JOIN: no settings row is not listed.
*/
public final class ListedUser {
private ListedUser() {
}
public static String of(String user, String settings) {
return PublicUser.of(user, settings) + " AND " + settings + ".show_in_owner_lists";
}
}
@@ -0,0 +1,40 @@
package auth_service.shared.account;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* The profile parts a user shows, by the names of the profile ticket. Core knows sections, not tiles: every
* {@code STATS_} tile is one group of the single STATS section.
*/
public final class ProfileSections {
private static final String STATS_PREFIX = "STATS_";
private static final String STATS = "STATS";
// The names Core stores; a tile id of any other kind is a user's own choice and says nothing to Core.
private static final Set<String> KNOWN = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", STATS);
private ProfileSections() {
}
/**
* @param tileIds the tile ids without their size
*/
public static List<String> of(List<String> tileIds) {
List<String> sections = new ArrayList<>(tileIds.stream().filter(KNOWN::contains).toList());
if (tileIds.stream().anyMatch(id -> id.startsWith(STATS_PREFIX)) && !sections.contains(STATS)) {
sections.add(STATS);
}
return sections;
}
/**
* @param tileIds the tile ids without their size
* @return the stat groups of Core, one per {@code STATS_} tile
*/
public static List<String> statGroups(List<String> tileIds) {
return tileIds.stream().filter(id -> id.startsWith(STATS_PREFIX)).map(id -> id.substring(STATS_PREFIX.length()))
.toList();
}
}
@@ -0,0 +1,16 @@
package auth_service.shared.account;
/**
* The rule Core uses to allow follows (#27): active, public profile on, with a display name. It matches
* {@code PublicProfileRepository.findPublicByDisplayName}. The settings alias may come from a LEFT JOIN: no settings
* row is not public.
*/
public final class PublicUser {
private PublicUser() {
}
public static String of(String user, String settings) {
return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + user + ".display_name IS NOT NULL";
}
}
@@ -1,8 +1,9 @@
package auth_service.shared.security; package auth_service.shared.security;
/** /**
* Port for the filter: the access token of a blocked or deleted user stops working before it expires. * Port for the filter: the access token of a blocked or deleted user, or one from before a role change, stops
* working before it expires.
*/ */
public interface ActiveUserCheck { public interface ActiveUserCheck {
boolean isActive(long userId); UserTokenState state(long userId);
} }
@@ -1,5 +1,7 @@
package auth_service.shared.security; package auth_service.shared.security;
import java.time.Instant;
/** /**
* Port interface for token generation. * Port interface for token generation.
* Decouples the login service from specific token implementations (e.g. JWT). * Decouples the login service from specific token implementations (e.g. JWT).
@@ -13,6 +15,16 @@ public interface TokenGenerator {
* @param role The active role of the login. * @param role The active role of the login.
* @return The generated token string. * @return The generated token string.
*/ */
String generateToken(String userId, Long steamId, Role role); default String generateToken(String userId, Long steamId, Role role) {
return generateToken(userId, steamId, role, null);
}
/**
* Same, but the token is never issued before {@code notBefore}, so the filter's role change check accepts it
* at once (see {@link UserTokenState#accepts}).
*
* @param notBefore lower bound of the {@code iat}, null for none
*/
String generateToken(String userId, Long steamId, Role role, Instant notBefore);
} }
@@ -1,16 +1,23 @@
package auth_service.shared.security; package auth_service.shared.security;
import java.security.Principal; import java.security.Principal;
import java.time.Instant;
/** /**
* @param role the active role of this login ("view as"), never above the stored one * @param role the active role of this login ("view as"), never above the stored one
* @param issuedAt {@code iat} of the token, null when it has none
*/ */
public record UserPrincipal( public record UserPrincipal(
String id, String id,
Long steamId, Long steamId,
Role role Role role,
Instant issuedAt
) implements Principal { ) implements Principal {
public UserPrincipal(String id, Long steamId, Role role) {
this(id, steamId, role, null);
}
public UserPrincipal(String id, Long steamId) { public UserPrincipal(String id, Long steamId) {
this(id, steamId, Role.USER); this(id, steamId, Role.USER);
} }
@@ -0,0 +1,20 @@
package auth_service.shared.security;
import java.time.Instant;
/**
* What the filter needs to know about the user of an access token.
*
* @param roleChangedAt null when the role never changed on the admin page
*/
public record UserTokenState(boolean active, Instant roleChangedAt) {
public static final UserTokenState INACTIVE = new UserTokenState(false, null);
/**
* A token issued before roleChangedAt is refused. roleChangedAt is the start of the second after the change.
*/
public boolean accepts(Instant issuedAt) {
return active && (roleChangedAt == null || (issuedAt != null && !issuedAt.isBefore(roleChangedAt)));
}
}
@@ -0,0 +1,14 @@
-- NULL: the role never changed on the admin page. A login token issued before this time is refused.
ALTER TABLE users ADD COLUMN role_changed_at TIMESTAMPTZ;
-- Log of role changes on the admin page. No foreign keys: the log outlives a purged user.
CREATE TABLE role_change (
id BIGSERIAL PRIMARY KEY,
actor_user_id BIGINT NOT NULL,
target_user_id BIGINT NOT NULL,
old_role VARCHAR(16) NOT NULL CHECK (old_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
new_role VARCHAR(16) NOT NULL CHECK (new_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN')),
changed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_role_change_target ON role_change (target_user_id, changed_at DESC);
@@ -0,0 +1,6 @@
-- Core item #3: the new UP_NEXT dashboard tile, added once at the end of every saved layout. A NULL layout
-- already shows the Frontend default, which has it. One-shot: a user who removes the tile later keeps it removed.
UPDATE user_settings
SET dashboard_tiles = array_append(dashboard_tiles, 'UP_NEXT:M')
WHERE dashboard_tiles IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM unnest(dashboard_tiles) t WHERE t LIKE 'UP_NEXT:%');
@@ -0,0 +1,3 @@
-- Opt-in to be shown by name in owner and user lists (#26). Off for every existing user; a user is listed only
-- together with the public profile.
ALTER TABLE user_settings ADD COLUMN show_in_owner_lists BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,83 @@
package auth_service.configuration;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.servlet.HandlerMapping;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RequestTimingFilterTest {
private final Logger logger = (Logger) LoggerFactory.getLogger(RequestTimingFilter.class);
private final ListAppender<ILoggingEvent> lines = new ListAppender<>();
@BeforeEach
void capture() {
lines.start();
logger.addAppender(lines);
}
@AfterEach
void release() {
logger.detachAppender(lines);
}
private String run(MockHttpServletRequest request, int status) throws Exception {
new RequestTimingFilter().doFilter(request, new MockHttpServletResponse(), new MockFilterChain() {
@Override
public void doFilter(ServletRequest req, ServletResponse res) {
((MockHttpServletResponse) res).setStatus(status);
}
});
assertThat(lines.list).hasSize(1);
assertThat(lines.list.get(0).getLevel()).isEqualTo(Level.INFO);
return lines.list.get(0).getFormattedMessage();
}
@Test
void logsMethodPatternStatusAndMillisButNotThePathOrQuery() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/games/4711");
request.setQueryString("q=abc");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/games/{appid}");
assertThat(run(request, 200)).matches("GET /api/games/\\{appid} 200 \\d+ ms");
}
@Test
void logsADashForARequestNoHandlerTook() throws Exception {
assertThat(run(new MockHttpServletRequest("POST", "/some/path/42"), 404)).matches("POST - 404 \\d+ ms");
}
@Test
void logsStatus500WhenTheChainThrows() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/x/1");
request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/x/{id}");
assertThatThrownBy(() -> new RequestTimingFilter().doFilter(request,
new MockHttpServletResponse(), (req, res) -> { throw new IllegalStateException("boom"); }))
.isInstanceOf(IllegalStateException.class);
assertThat(lines.list).hasSize(1);
assertThat(lines.list.get(0).getFormattedMessage()).matches("GET /api/x/\\{id} 500 \\d+ ms");
}
@Test
void logsNothingForTheActuator() throws Exception {
new RequestTimingFilter().doFilter(new MockHttpServletRequest("GET", "/actuator/health"),
new MockHttpServletResponse(), new MockFilterChain());
assertThat(lines.list).isEmpty();
}
}
@@ -93,8 +93,12 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ReturnsTheUser() { void parseLoginToken_ReturnsTheUser() {
var token = jwtService.generateToken("0815", 123456789L, Role.USER); var token = jwtService.generateToken("0815", 123456789L, Role.USER);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)), UserPrincipal principal = jwtService.parseLoginToken(token).orElseThrow();
jwtService.parseLoginToken(token));
assertEquals("0815", principal.id());
assertEquals(123456789L, principal.steamId());
assertEquals(Role.USER, principal.role());
assertFalse(principal.issuedAt().isAfter(java.time.Instant.now()));
} }
@Test @Test
@@ -111,6 +115,16 @@ class JwtServiceTest extends AbstractUnitTest {
assertTrue(secondsLeft > 14 * 60 && secondsLeft <= 15 * 60, "expires in " + secondsLeft + "s"); assertTrue(secondsLeft > 14 * 60 && secondsLeft <= 15 * 60, "expires in " + secondsLeft + "s");
} }
@Test
void generateToken_IsNeverIssuedBeforeNotBefore() {
var notBefore = java.time.Instant.now().plusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var token = jwtService.generateToken("0815", null, Role.USER, notBefore);
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
assertEquals(notBefore, claims.getIssuedAt().toInstant());
}
@Test @Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() { void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of()); var ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of());
@@ -298,7 +298,8 @@ class SessionFlowTest extends AbstractIntegrationTest {
.header("Access-Control-Request-Headers", "x-tbp-client")) .header("Access-Control-Request-Headers", "x-tbp-client"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND)) .andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
.andExpect(header().string("Access-Control-Allow-Credentials", "true")); .andExpect(header().string("Access-Control-Allow-Credentials", "true"))
.andExpect(header().string("Access-Control-Max-Age", "3600"));
mockMvc.perform(options("/api/auth/refresh") mockMvc.perform(options("/api/auth/refresh")
.header("Origin", "https://evil.andreas-dahm.eu") .header("Origin", "https://evil.andreas-dahm.eu")
@@ -222,4 +222,31 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
.content("{\"email\":\"" + EMAIL + "\"}")) .content("{\"email\":\"" + EMAIL + "\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE")); .andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
} }
@Test
void theOnlyAdmin_CannotDeleteTheOwnAccount() throws Exception {
jdbc.update("UPDATE users SET role = 'USER' WHERE role = 'PLATFORM_ADMIN'");
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", EMAIL);
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
deleteAccount(access, PASSWORD).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("LAST_ADMIN"));
assertThat(jdbc.queryForObject("SELECT deleted_at IS NULL FROM users WHERE email = ?", Boolean.class, EMAIL)).isTrue();
}
@Test
void oneOfTwoAdmins_CanDeleteTheOwnAccount() throws Exception {
String otherAdmin = "delete-flow-admin@example.com";
jdbc.update("UPDATE users SET role = 'USER' WHERE role = 'PLATFORM_ADMIN'");
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", EMAIL);
jdbc.update("DELETE FROM users WHERE email = ?", otherAdmin);
jdbc.update("INSERT INTO users (email, password, enabled, role) VALUES (?, 'x', true, 'PLATFORM_ADMIN')", otherAdmin);
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
try {
deleteAccount(access, PASSWORD).andExpect(status().isNoContent());
} finally {
jdbc.update("DELETE FROM users WHERE email = ?", otherAdmin);
}
}
} }
@@ -0,0 +1,77 @@
package auth_service.modules.account.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* A listed user (#26) who deletes the account. Core is not reachable in tests, like Core down in production.
*/
class DeletedAccountListingTest extends AbstractIntegrationTest {
private static final String EMAIL = "delete-listed@example.com";
private static final String PASSWORD = "correct horse battery";
@MockitoBean
private AccountStatePublisher accountStatePublisher;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
private long userId;
@BeforeEach
void seedListedUser() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, ?, true, ?) "
+ "RETURNING id", Long.class, EMAIL, passwordEncoder.encode(PASSWORD), "DeleteListed");
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile, show_in_owner_lists) "
+ "VALUES (?, 'de', 'dark', true, true)", userId);
}
@AfterEach
void cleanUp() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
}
@Test
void deleteWhileCoreIsDown_PushesTheUnlistedState() throws Exception {
Cookie access = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
mockMvc.perform(delete("/api/users/me")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(access)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isNoContent());
verify(accountStatePublisher, timeout(2000)).publish(userId);
}
}
@@ -56,12 +56,62 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() { void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice); jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true)); assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false,
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true), List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")));
new CoreAccountState(bob, null, null, false)); assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false,
List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")),
new CoreAccountState(bob, null, null, false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")));
assertThat(repository.findState(-1L)).isEmpty(); assertThat(repository.findState(-1L)).isEmpty();
} }
@Test
void findStates_ListedOnlyForAnActivePublicOptedInUserWithAName() {
long listed = insertUser("listed@example.com");
jdbc.update("update users set display_name = 'Listed' where id = ?", listed);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", listed);
assertThat(repository.findState(listed)).get().extracting(CoreAccountState::listed).isEqualTo(true);
String[] breakers = {"update users set enabled = false where id = ?",
"update users set deleted_at = now() where id = ?",
"update users set status = 'PENDING' where id = ?",
"update users set display_name = null where id = ?",
"update user_settings set public_profile = false where user_id = ?",
"update user_settings set show_in_owner_lists = false where user_id = ?"};
for (String breaker : breakers) {
jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Listed' where id = ?", listed);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", listed);
jdbc.update(breaker, listed);
assertThat(repository.findState(listed)).as(breaker).get().extracting(CoreAccountState::listed).isEqualTo(false);
assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == listed)
.extracting(CoreAccountState::listed).containsExactly(false);
}
}
@Test
void findAllStates_CarryTheListedFlag() {
jdbc.update("update users set display_name = 'Alice' where id = ?", alice);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", alice);
assertThat(repository.findAllStates()).extracting(CoreAccountState::listed).containsExactly(true, false);
}
@Test
void findStates_CarryTheRoleChangeTime() {
jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice);
assertThat(repository.findState(alice)).get().extracting(CoreAccountState::roleChangedAt).isEqualTo(NOW);
assertThat(repository.findAllStates()).extracting(CoreAccountState::roleChangedAt).containsExactly(NOW, null);
}
@Test
void lockActiveAdmins_ListsTheActiveAdmins() {
jdbc.update("update users set role = 'PLATFORM_ADMIN' where id = ?", bob);
jdbc.update("update users set role = 'PLATFORM_ADMIN', enabled = false where id = ?", alice);
assertThat(repository.lockActiveAdmins()).containsExactly(bob);
}
@Test @Test
void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() { void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() {
assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1); assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1);
@@ -224,4 +274,48 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
assertThat(activeTokens(alice)).isZero(); assertThat(activeTokens(alice)).isZero();
assertThat(activeTokens(bob)).isEqualTo(2); assertThat(activeTokens(bob)).isEqualTo(2);
} }
@Test
void findStates_PublicProfileOnlyForAnActivePublicUserWithAName_NoOptInNeeded() {
long open = insertUser("open@example.com");
jdbc.update("update users set display_name = 'Open' where id = ?", open);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = false where user_id = ?", open);
assertThat(repository.findState(open)).get().extracting(CoreAccountState::publicProfile).isEqualTo(true);
String[] breakers = {"update users set enabled = false where id = ?",
"update users set deleted_at = now() where id = ?",
"update users set status = 'PENDING' where id = ?",
"update users set display_name = null where id = ?",
"update user_settings set public_profile = false where user_id = ?"};
for (String breaker : breakers) {
jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Open' where id = ?", open);
jdbc.update("update user_settings set public_profile = true where user_id = ?", open);
jdbc.update(breaker, open);
assertThat(repository.findState(open)).as(breaker).get().extracting(CoreAccountState::publicProfile).isEqualTo(false);
assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == open)
.extracting(CoreAccountState::publicProfile).containsExactly(false);
}
}
@Test
void findStates_ProfileSectionsAreTheTileIdsWithAllStatsTilesCollapsedToOne() {
jdbc.update("update user_settings set profile_tiles = '{SUMMARY:L,STATS_BACKLOG:M,PLAYING:S,STATS_PLAYTIME:M}' where user_id = ?",
alice);
assertThat(repository.findState(alice)).get().extracting(CoreAccountState::profileSections)
.isEqualTo(List.of("SUMMARY", "PLAYING", "STATS"));
assertThat(repository.findAllStates()).extracting(CoreAccountState::profileSections)
.containsExactly(List.of("SUMMARY", "PLAYING", "STATS"), List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"));
}
@Test
void findStates_UserWithoutASettingsRow_HasNoSectionsAndIsNotPublic() {
jdbc.update("delete from user_settings where user_id = ?", alice);
assertThat(repository.findState(alice)).get().satisfies(state -> {
assertThat(state.profileSections()).isNull();
assertThat(state.publicProfile()).isFalse();
});
}
} }
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest { class AccountCleanupServiceTest extends AbstractUnitTest {
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true)); private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false, false, null));
private static final Duration RETENTION = Duration.ofDays(30); private static final Duration RETENTION = Duration.ofDays(30);
@@ -1,8 +1,10 @@
package auth_service.modules.account.service; package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken; import auth_service.shared.security.OpaqueToken;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
@@ -50,12 +52,15 @@ class AccountServiceTest extends AbstractUnitTest {
@Mock @Mock
private AccountMailer accountMailer; private AccountMailer accountMailer;
@Mock
private AccountStatePublisher accountStatePublisher;
private AccountService service; private AccountService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer, service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer,
TransactionOperations.withoutTransaction(), Duration.ofDays(30)); accountStatePublisher, TransactionOperations.withoutTransaction(), Duration.ofDays(30));
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash")); lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true); lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1); lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1);
@@ -83,6 +88,25 @@ class AccountServiceTest extends AbstractUnitTest {
assertThat(deletedFrom.getValue()).isBetween(earliest, latest); assertThat(deletedFrom.getValue()).isBetween(earliest, latest);
} }
@Test
void deleteAccount_TheLastAdminIsRefusedAndNothingIsFlagged() {
when(accountRepository.lockActiveAdmins()).thenReturn(List.of(USER_ID));
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(LastAdminException.class);
verify(accountRepository, never()).flagDeleted(anyLong(), any());
verifyNoInteractions(accountMailer, coreAccountService);
}
@Test
void deleteAccount_OneOfTwoAdminsIsFlagged() {
when(accountRepository.lockActiveAdmins()).thenReturn(List.of(USER_ID, 43L));
service.deleteAccount(USER_ID, "right");
verify(accountRepository).flagDeleted(eq(USER_ID), any());
}
@Test @Test
void deleteAccount_WrongPasswordFlagsNothing() { void deleteAccount_WrongPasswordFlagsNothing() {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
@@ -121,6 +145,7 @@ class AccountServiceTest extends AbstractUnitTest {
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right")); assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right"));
verify(accountRepository).flagDeleted(eq(USER_ID), any()); verify(accountRepository).flagDeleted(eq(USER_ID), any());
verify(accountMailer).accountDeleted(eq(USER_ID), any()); verify(accountMailer).accountDeleted(eq(USER_ID), any());
verify(accountStatePublisher).publish(USER_ID);
} }
@Test @Test
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientException;
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -71,8 +72,9 @@ class CoreAccountServiceTest extends AbstractUnitTest {
expect("applyStates"); expect("applyStates");
expect("replaceStates"); expect("replaceStates");
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true))); service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false))); Instant.parse("2026-10-04T12:00:00Z"), true, true, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))));
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))));
core.verify(); core.verify();
} }
@@ -5,24 +5,37 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class CoreAccountStatePublisherTest extends AbstractUnitTest { class CoreAccountStatePublisherTest extends AbstractUnitTest {
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true); private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false, false, null);
@Mock @Mock
private AccountRepository accountRepository; private AccountRepository accountRepository;
@@ -32,10 +45,13 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
private CoreAccountStatePublisher publisher; private CoreAccountStatePublisher publisher;
@Mock
private TaskScheduler scheduler;
// Runs each task at once, so the test sees the call. // Runs each task at once, so the test sees the call.
@BeforeEach @BeforeEach
void setUp() { void setUp() {
publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run); publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run, scheduler);
} }
@Test @Test
@@ -71,6 +87,58 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
assertDoesNotThrow(() -> publisher.publish(7L)); assertDoesNotThrow(() -> publisher.publish(7L));
} }
@Test
void coreDownOnce_RetriesAfter10sAndSendsTheCurrentState() {
CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true, false, null);
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE), Optional.of(newer));
doThrow(new RestClientException("Core down")).doNothing().when(coreAccountService).applyStates(any());
ArgumentCaptor<Runnable> retry = ArgumentCaptor.forClass(Runnable.class);
ArgumentCaptor<Instant> at = ArgumentCaptor.forClass(Instant.class);
Instant before = Instant.now();
publisher.publish(7L);
verify(scheduler).schedule(retry.capture(), at.capture());
assertThat(Duration.between(before, at.getValue())).isBetween(Duration.ofSeconds(9), Duration.ofSeconds(11));
retry.getValue().run();
verify(coreAccountService).applyStates(List.of(STATE));
verify(coreAccountService).applyStates(List.of(newer));
verify(accountRepository, times(2)).findState(7L);
}
@Test
void coreDownFourTimes_StopsAfterThreeRetries() {
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
doThrow(new RestClientException("Core down")).when(coreAccountService).applyStates(any());
List<Runnable> tasks = new ArrayList<>();
List<Duration> delays = new ArrayList<>();
doAnswer(call -> {
tasks.add(call.getArgument(0));
delays.add(Duration.between(Instant.now(), call.<Instant>getArgument(1)));
return null;
}).when(scheduler).schedule(any(Runnable.class), any(Instant.class));
publisher.publish(7L);
for (int next = 0; next < 3; next++) {
tasks.get(next).run();
}
assertThat(tasks).hasSize(3);
verify(coreAccountService, times(4)).applyStates(any());
assertThat(delays.get(0)).isBetween(Duration.ofSeconds(9), Duration.ofSeconds(11));
assertThat(delays.get(1)).isBetween(Duration.ofSeconds(55), Duration.ofSeconds(61));
assertThat(delays.get(2)).isBetween(Duration.ofSeconds(295), Duration.ofSeconds(301));
}
@Test
void success_SchedulesNoRetry() {
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
publisher.publish(7L);
verifyNoInteractions(scheduler);
}
@Test @Test
void unknownUser_SendsNothing() { void unknownUser_SendsNothing() {
when(accountRepository.findState(7L)).thenReturn(Optional.empty()); when(accountRepository.findState(7L)).thenReturn(Optional.empty());
@@ -79,4 +147,29 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
verify(coreAccountService, never()).applyStates(any()); verify(coreAccountService, never()).applyStates(any());
} }
@Test
void parallelSendsOfOneUser_NeverRunAtTheSameTime() throws Exception {
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
AtomicInteger running = new AtomicInteger();
AtomicInteger mostRunning = new AtomicInteger();
doAnswer(call -> {
mostRunning.accumulateAndGet(running.incrementAndGet(), Math::max);
Thread.sleep(50);
running.decrementAndGet();
return null;
}).when(coreAccountService).applyStates(any());
ExecutorService threads = Executors.newFixedThreadPool(4);
CoreAccountStatePublisher parallel = new CoreAccountStatePublisher(accountRepository, coreAccountService,
threads, scheduler);
for (int i = 0; i < 4; i++) {
parallel.publish(7L);
}
threads.shutdown();
assertThat(threads.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
verify(coreAccountService, times(4)).applyStates(any());
assertThat(mostRunning.get()).isEqualTo(1);
}
} }
@@ -0,0 +1,144 @@
package auth_service.modules.admin;
import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.service.AccountService;
import auth_service.modules.admin.exception.AdminActionRefusedException;
import auth_service.modules.admin.service.RoleChangeService;
import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.security.Role;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import java.util.Arrays;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Two admins are the only admins. The first transaction locks both and demotes one of them, not committed. The second
* write must wait on the locked admin rows and then see the new state. The order is fixed, not timed.
*/
class AdminLockRaceTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
@Autowired
private JdbcTemplate jdbc;
@Autowired
private DataSource dataSource;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private RoleChangeService roleChangeService;
@Autowired
private AccountService accountService;
private long adminA;
private long adminB;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private long insertAdmin(String email) {
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
+ "VALUES (?, ?, true, 'PLATFORM_ADMIN', ?) RETURNING id", Long.class, email,
passwordEncoder.encode(PASSWORD), email.substring(0, 12));
addSettingsRows();
return id;
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM users");
adminA = insertAdmin("lock-race-a@example.com");
adminB = insertAdmin("lock-race-b@example.com");
}
@AfterEach
void cleanUp() {
executor.shutdownNow();
jdbc.update("DELETE FROM users WHERE email LIKE 'lock-race-%'");
}
private <T> Future<T> asAdmin(long userId, Callable<T> call) {
return executor.submit(() -> {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal(String.valueOf(userId), null, Role.PLATFORM_ADMIN), null,
Arrays.stream(Role.values()).map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).toList()));
try {
return call.call();
} finally {
SecurityContextHolder.clearContext();
}
});
}
private void waitForLock() throws InterruptedException {
long deadline = System.currentTimeMillis() + 10_000;
while (jdbc.queryForObject("SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock'",
Integer.class) == 0) {
assertThat(System.currentTimeMillis()).as("the second write waits for the first").isLessThan(deadline);
Thread.sleep(20);
}
}
private <T> Future<T> secondWrite(long actor, Callable<T> call) throws Exception {
try (Connection first = dataSource.getConnection(); Statement statement = first.createStatement()) {
first.setAutoCommit(false);
statement.executeQuery(ActiveAdmins.LOCK).close();
statement.executeUpdate("UPDATE users SET role = 'USER' WHERE id = " + adminB);
Future<T> second = asAdmin(actor, call);
waitForLock();
Thread.sleep(300);
assertThat(second.isDone()).as("the second write still waits").isFalse();
first.commit();
return second;
}
}
@Test
void adminDemotedByAnotherTransaction_CannotDemoteTheOtherAdmin() throws Exception {
Future<Void> second = secondWrite(adminB, () -> {
roleChangeService.changeRole(adminB, adminA, Role.PLATFORM_ADMIN, Role.USER);
return null;
});
assertThatThrownBy(() -> second.get(10, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class)
.cause().isInstanceOfSatisfying(AdminActionRefusedException.class,
e -> assertThat(e.getCode()).isEqualTo("ACTOR_NOT_ADMIN"));
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, adminA))
.isEqualTo("PLATFORM_ADMIN");
}
@Test
void adminWhoBecomesTheLastAdminWhileWaiting_CannotDeleteTheAccount() throws Exception {
Future<Void> second = secondWrite(adminA, () -> {
accountService.deleteAccount(adminA, PASSWORD);
return null;
});
assertThatThrownBy(() -> second.get(10, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class)
.cause().isInstanceOf(LastAdminException.class);
assertThat(jdbc.queryForObject("SELECT deleted_at IS NULL FROM users WHERE id = ?", Boolean.class, adminA)).isTrue();
}
}
@@ -0,0 +1,132 @@
package auth_service.modules.admin;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.admin.service.RoleChangeService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.Role;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* A login reads the role before its bcrypt check. A demotion that commits during that check must not leave the login
* with an access token that carries the old role and still passes the role change check.
*/
class RoleChangeLoginRaceTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String ADMIN = "login-race-admin@example.com";
private static final String VICTIM = "login-race-victim@example.com";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@MockitoSpyBean
private PasswordEncoder passwordEncoder;
@Autowired
private RoleChangeService roleChangeService;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private long adminId;
private long victimId;
private long insertAdmin(String email) {
jdbc.update("DELETE FROM users WHERE email = ?", email);
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
+ "VALUES (?, ?, true, 'PLATFORM_ADMIN', ?) RETURNING id", Long.class, email,
passwordEncoder.encode(PASSWORD), email.substring(0, 16));
addSettingsRows();
return id;
}
@BeforeEach
void seed() {
adminId = insertAdmin(ADMIN);
victimId = insertAdmin(VICTIM);
}
@AfterEach
void cleanUp() {
executor.shutdownNow();
jdbc.update("DELETE FROM role_change WHERE target_user_id = ?", victimId);
jdbc.update("DELETE FROM users WHERE email LIKE 'login-race-%'");
}
private void demoteVictimInAnotherThread() throws Exception {
executor.submit(() -> {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal(String.valueOf(adminId), null, Role.PLATFORM_ADMIN), null,
Arrays.stream(Role.values()).map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).toList()));
try {
roleChangeService.changeRole(adminId, victimId, Role.PLATFORM_ADMIN, Role.USER);
} finally {
SecurityContextHolder.clearContext();
}
return null;
}).get(10, TimeUnit.SECONDS);
}
@Test
void loginRacingADemotionGetsNoTokenWithTheOldRole() throws Exception {
AtomicBoolean armed = new AtomicBoolean(true);
doAnswer(invocation -> {
boolean matches = (boolean) invocation.callRealMethod();
if (armed.getAndSet(false)) {
demoteVictimInAnotherThread();
Instant validFrom = jdbc.queryForObject("SELECT role_changed_at FROM users WHERE id = ?",
Timestamp.class, victimId).toInstant();
// The bcrypt check of a real login can end after the next second starts.
while (Instant.now().isBefore(validFrom.plusMillis(5))) {
Thread.sleep(5);
}
}
return matches;
}).when(passwordEncoder).matches(any(), any());
MockHttpServletResponse login = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + VICTIM + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse();
assertThat(armed.get()).as("the demotion ran inside the login").isFalse();
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, victimId)).isEqualTo("USER");
int adminList = login.getStatus() != 200 ? 401 : mockMvc.perform(get("/api/admin/users")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE,
login.getCookie(AuthCookies.ACCESS_COOKIE).getValue())))
.andReturn().getResponse().getStatus();
assertThat(adminList).as("admin list with the token of a login that raced the demotion").isIn(401, 403);
}
}
@@ -337,4 +337,146 @@ class AdminFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN")); .andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN"));
block(admin, 999_999_999L, true).andExpect(status().isNotFound()); block(admin, 999_999_999L, true).andExpect(status().isNotFound());
} }
private ResultActions changeRole(Session session, long userId, String role, String expectedRole) throws Exception {
return mockMvc.perform(put("/api/admin/users/" + userId + "/role").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"role\":\"" + role + "\",\"expectedRole\":\"" + expectedRole + "\"}"));
}
private ResultActions roleChanges(Session session, long userId) throws Exception {
return mockMvc.perform(get("/api/admin/users/" + userId + "/role-changes")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
}
private String activeRole(Session session) throws Exception {
return com.jayway.jsonpath.JsonPath.read(mockMvc.perform(get("/api/users/me")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))).andReturn().getResponse()
.getContentAsString(), "$.activeRole");
}
@Test
void roleChange_GrantEndsTheOldTokenAndTheNewOneCarriesTheRole() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
assertThat(me(player)).isEqualTo(200);
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isNoContent());
assertThat(me(player)).isEqualTo(401);
assertThat(activeRole(refresh(player))).isEqualTo("AUDITOR");
roleChanges(admin, playerId).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].actorEmail").value(ADMIN))
.andExpect(jsonPath("$.results[0].oldRole").value("USER"))
.andExpect(jsonPath("$.results[0].newRole").value("AUDITOR"));
assertThat(audits("ROLE_CHANGE", playerId)).isEqualTo(1);
}
@Test
void roleChange_SameRoleWritesNothing() throws Exception {
changeRole(Session.of(login(ADMIN)), playerId, "USER", "USER").andExpect(status().isNoContent());
assertThat(audits("ROLE_CHANGE", playerId)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM role_change WHERE target_user_id = ?", Long.class, playerId)).isZero();
}
@Test
void roleChange_DemotedAdminLosesTheRightsAtOnce() throws Exception {
Session admin = Session.of(login(ADMIN));
Session other = Session.of(login(OTHER_ADMIN));
listUsers(other).andExpect(status().isOk());
changeRole(admin, otherAdminId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
listUsers(other).andExpect(status().isUnauthorized());
listUsers(refresh(other)).andExpect(status().isForbidden());
}
@Test
void roleChange_AdminWhoViewsAsUserIsAuditorAfterTheDemotionToAuditor() throws Exception {
Session admin = Session.of(login(ADMIN));
Session asUser = Session.of(viewAs(Session.of(login(OTHER_ADMIN)), "USER"));
changeRole(admin, otherAdminId, "AUDITOR", "PLATFORM_ADMIN").andExpect(status().isNoContent());
assertThat(activeRole(refresh(asUser))).isEqualTo("AUDITOR");
}
@Test
void roleChange_RefusesOwnRoleStaleRoleBlockedTargetBadRoleAndUnknownUser() throws Exception {
Session admin = Session.of(login(ADMIN));
changeRole(admin, jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, ADMIN), "USER",
"PLATFORM_ADMIN").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SELF_ACTION"));
changeRole(admin, playerId, "AUDITOR", "AUDITOR").andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ROLE_CHANGED"));
changeRole(admin, playerId, "GOD", "USER").andExpect(status().isBadRequest());
changeRole(admin, 999_999_999L, "AUDITOR", "USER").andExpect(status().isNotFound());
block(admin, playerId, true).andExpect(status().isNoContent());
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TARGET_IS_BLOCKED"));
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, playerId)).isEqualTo("USER");
}
@Test
void roleEndpoints_AreForAdminsOnly() throws Exception {
Session player = Session.of(login(PLAYER));
jdbc.update("UPDATE users SET role = 'AUDITOR' WHERE email = ?", OTHER_ADMIN);
Session auditor = Session.of(login(OTHER_ADMIN));
Session viewingAsAuditor = Session.of(viewAs(Session.of(login(ADMIN)), "AUDITOR"));
for (Session session : new Session[]{player, auditor, viewingAsAuditor}) {
changeRole(session, playerId, "AUDITOR", "USER").andExpect(status().isForbidden());
roleChanges(session, playerId).andExpect(status().isForbidden());
}
mockMvc.perform(get("/api/admin/users/" + playerId + "/role-changes")).andExpect(status().isUnauthorized());
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, playerId)).isEqualTo("USER");
}
@Test
void roleChange_TokenFromTheSecondOfTheDemotionLosesTheAdminRights() throws Exception {
Session admin = Session.of(login(ADMIN));
while (System.currentTimeMillis() % 1000 > 50) {
Thread.onSpinWait();
}
Session other = Session.of(login(OTHER_ADMIN));
changeRole(admin, otherAdminId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
listUsers(other).andExpect(status().isUnauthorized());
}
@Test
void roleChange_RefreshInTheSecondOfTheChangeGivesAValidToken() throws Exception {
Session admin = Session.of(login(ADMIN));
while (System.currentTimeMillis() % 1000 > 50) {
Thread.onSpinWait();
}
Session player = Session.of(login(PLAYER));
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isNoContent());
Session refreshed = refresh(player);
assertThat(me(player)).isEqualTo(401);
assertThat(me(refreshed)).isEqualTo(200);
assertThat(activeRole(refreshed)).isEqualTo("AUDITOR");
}
@Test
void roleChange_TokenFromBetweenTwoChangesInOneSecondLosesTheRole() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
while (System.currentTimeMillis() % 1000 > 50) {
Thread.onSpinWait();
}
changeRole(admin, playerId, "PLATFORM_ADMIN", "USER").andExpect(status().isNoContent());
Session promoted = refresh(player);
changeRole(admin, playerId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
listUsers(promoted).andExpect(status().isUnauthorized());
}
} }
@@ -0,0 +1,110 @@
package auth_service.modules.admin.repo;
import auth_service.modules.admin.model.RoleChange;
import auth_service.modules.admin.model.RoleTarget;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class RoleChangeRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
@Autowired
private RoleChangeRepository repository;
@Autowired
private JdbcTemplate jdbc;
private long admin;
private long otherAdmin;
private long user;
private long insert(String email, String role) {
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role) VALUES (?, 'x', true, ?) RETURNING id",
Long.class, email, role);
}
private void addLogin(long userId) {
jdbc.update("""
INSERT INTO refresh_tokens (user_id, token_hash, family_id, expires_at, created_at, active_role)
VALUES (?, ?, ?, now() + interval '30 days', now(), 'USER')""", userId, UUID.randomUUID().toString(),
UUID.randomUUID());
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM role_change");
jdbc.update("DELETE FROM users");
admin = insert("admin@example.com", "PLATFORM_ADMIN");
otherAdmin = insert("other-admin@example.com", "PLATFORM_ADMIN");
user = insert("user@example.com", "USER");
}
@Test
void lockActiveAdmins_ListsOnlyActiveAdminsByIdAndNoOtherRole() {
long blocked = insert("blocked-admin@example.com", "PLATFORM_ADMIN");
jdbc.update("UPDATE users SET enabled = false WHERE id = ?", blocked);
long deleted = insert("deleted-admin@example.com", "PLATFORM_ADMIN");
jdbc.update("UPDATE users SET deleted_at = now() WHERE id = ?", deleted);
long pending = insert("pending-admin@example.com", "PLATFORM_ADMIN");
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", pending);
assertThat(repository.lockActiveAdmins()).containsExactly(admin, otherAdmin);
}
@Test
void lockTarget_ReadsRoleBlockDeletedAndStatus() {
jdbc.update("UPDATE users SET enabled = false WHERE id = ?", user);
assertThat(repository.lockTarget(user)).contains(new RoleTarget(Role.USER, true, false, "ACTIVE", null));
jdbc.update("UPDATE users SET status = 'DENIED' WHERE id = ?", user);
assertThat(repository.lockTarget(user)).get().extracting(RoleTarget::deleted).isEqualTo(true);
assertThat(repository.lockTarget(-1L)).isEmpty();
}
@Test
void updateRole_SetsRoleAndTimeAndClearsTheActiveRoleOfOpenLoginsOnly() {
addLogin(user);
addLogin(user);
jdbc.update("UPDATE refresh_tokens SET revoked_at = now() WHERE id = (SELECT min(id) FROM refresh_tokens WHERE user_id = ?)", user);
addLogin(otherAdmin);
repository.updateRole(user, Role.AUDITOR, NOW);
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, user)).isEqualTo("AUDITOR");
assertThat(jdbc.queryForObject("SELECT role_changed_at FROM users WHERE id = ?", Timestamp.class, user).toInstant())
.isEqualTo(NOW);
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL",
String.class, user)).containsOnlyNulls().hasSize(1);
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NOT NULL",
String.class, user)).containsExactly("USER");
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ?", String.class, otherAdmin))
.containsExactly("USER");
}
@Test
void findChanges_NewestFirstLimitedWithTheActorEmailAndNullForAGoneActor() {
repository.insertChange(admin, user, Role.USER, Role.AUDITOR, NOW.minusSeconds(20));
repository.insertChange(otherAdmin, user, Role.AUDITOR, Role.PLATFORM_ADMIN, NOW.minusSeconds(10));
repository.insertChange(admin, user, Role.PLATFORM_ADMIN, Role.USER, NOW);
repository.insertChange(admin, otherAdmin, Role.USER, Role.AUDITOR, NOW);
jdbc.update("DELETE FROM users WHERE id = ?", otherAdmin);
assertThat(repository.findChanges(user, 10)).containsExactly(
new RoleChange(admin, "admin@example.com", Role.PLATFORM_ADMIN, Role.USER, NOW),
new RoleChange(otherAdmin, null, Role.AUDITOR, Role.PLATFORM_ADMIN, NOW.minusSeconds(10)),
new RoleChange(admin, "admin@example.com", Role.USER, Role.AUDITOR, NOW.minusSeconds(20)));
assertThat(repository.findChanges(user, 2)).hasSize(2);
}
}
@@ -0,0 +1,176 @@
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.Role;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class RoleChangeServiceTest extends AbstractUnitTest {
private static final long ACTOR = 1L;
private static final long TARGET = 2L;
@Mock
private RoleChangeRepository repository;
@Mock
private AdminAuditLog adminAuditLog;
@Mock
private AccountStatePublisher accountStatePublisher;
private RoleChangeService service;
@BeforeEach
void setUp() {
service = new RoleChangeService(repository, adminAuditLog, accountStatePublisher);
lenient().when(repository.lockActiveAdmins()).thenReturn(List.of(ACTOR));
target(Role.USER, false, false, "ACTIVE");
}
private void target(Role role, boolean blocked, boolean deleted, String status) {
target(role, blocked, deleted, status, null);
}
private void target(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
lenient().when(repository.lockTarget(TARGET))
.thenReturn(Optional.of(new RoleTarget(role, blocked, deleted, status, roleChangedAt)));
}
private String refusal(Role expectedRole, Role newRole) {
try {
service.changeRole(ACTOR, TARGET, expectedRole, newRole);
} catch (AdminActionRefusedException e) {
return e.getCode();
}
throw new AssertionError("not refused");
}
private void verifyNothingWritten() {
verify(repository, never()).updateRole(anyLong(), any(), any());
verify(repository, never()).insertChange(anyLong(), anyLong(), any(), any(), any());
verifyNoInteractions(adminAuditLog, accountStatePublisher);
}
@Test
void grant_WritesTheRoleTheLogTheAuditAndTellsCore() {
service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR);
Instant before = Instant.now();
ArgumentCaptor<Instant> validFrom = ArgumentCaptor.forClass(Instant.class);
verify(repository).updateRole(eq(TARGET), eq(Role.AUDITOR), validFrom.capture());
verify(repository).insertChange(eq(ACTOR), eq(TARGET), eq(Role.USER), eq(Role.AUDITOR), any());
assertThat(validFrom.getValue()).isEqualTo(validFrom.getValue().truncatedTo(ChronoUnit.SECONDS))
.isAfter(before.minusSeconds(1)).isBeforeOrEqualTo(before.plusSeconds(2));
verify(adminAuditLog).record(ACTOR, "ROLE_CHANGE", TARGET, "USER>AUDITOR");
verify(accountStatePublisher).publish(TARGET);
}
@Test
void secondChangeInTheSameSecond_IsValidFromAfterTheFirst() {
Instant previous = Instant.now().truncatedTo(ChronoUnit.SECONDS).plusSeconds(1);
target(Role.AUDITOR, false, false, "ACTIVE", previous);
service.changeRole(ACTOR, TARGET, Role.AUDITOR, Role.USER);
verify(repository).updateRole(TARGET, Role.USER, previous.plusSeconds(1));
}
@Test
void changeRole_LocksTheAdminsBeforeTheTarget() {
service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR);
InOrder order = inOrder(repository);
order.verify(repository).lockActiveAdmins();
order.verify(repository).lockTarget(TARGET);
order.verify(repository).updateRole(eq(TARGET), eq(Role.AUDITOR), any());
}
@Test
void ownRole_IsRefusedBeforeAnyLock() {
assertThatThrownBy(() -> service.changeRole(ACTOR, ACTOR, Role.PLATFORM_ADMIN, Role.USER))
.isInstanceOfSatisfying(AdminActionRefusedException.class, e -> assertThat(e.getCode()).isEqualTo("SELF_ACTION"));
verifyNoInteractions(repository);
verifyNothingWritten();
}
@Test
void actorWhoIsNoAdminAnyMore_IsRefused() {
when(repository.lockActiveAdmins()).thenReturn(List.of(9L));
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("ACTOR_NOT_ADMIN");
verifyNothingWritten();
}
@Test
void staleExpectedRole_IsRefused() {
assertThat(refusal(Role.AUDITOR, Role.PLATFORM_ADMIN)).isEqualTo("ROLE_CHANGED");
verifyNothingWritten();
}
@Test
void deletedBlockedPendingAndUnconfirmedTargets_AreRefused() {
target(Role.USER, false, true, "ACTIVE");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("ALREADY_DELETED");
target(Role.USER, true, false, "ACTIVE");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_BLOCKED");
target(Role.USER, false, false, "PENDING");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_PENDING");
target(Role.USER, false, false, "UNCONFIRMED");
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_UNCONFIRMED");
verifyNothingWritten();
}
@Test
void unknownTarget_IsNotFound() {
when(repository.lockTarget(TARGET)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR))
.isInstanceOf(TargetUserNotFoundException.class);
verifyNothingWritten();
}
@Test
void sameRole_WritesNothing() {
service.changeRole(ACTOR, TARGET, Role.USER, Role.USER);
verifyNothingWritten();
}
@Test
void findChanges_ReadsTheLog() {
List<RoleChange> log = List.of(new RoleChange(ACTOR, "a@example.com", Role.USER, Role.AUDITOR, Instant.now()));
when(repository.findChanges(TARGET, 50)).thenReturn(log);
assertThat(service.findChanges(TARGET)).isEqualTo(log);
}
}
@@ -1,7 +1,5 @@
package auth_service.modules.login.service; package auth_service.modules.login.service;
import auth_service.shared.security.Role;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.AccountBlockedException; import auth_service.modules.login.exception.AccountBlockedException;
import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.AccountPendingException;
import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.InvalidCredentialsException;
@@ -47,9 +45,6 @@ class LoginServiceTest extends AbstractUnitTest {
@Mock @Mock
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Mock
private TokenGenerator tokenGenerator;
@Mock @Mock
private LoginAttemptService loginAttemptService; private LoginAttemptService loginAttemptService;
@@ -65,8 +60,8 @@ class LoginServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash"); lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash");
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService, loginService = new LoginService(minimalUserRepository, passwordEncoder, loginAttemptService, refreshTokenService,
refreshTokenService, cacheManager); cacheManager);
user = User.builder() user = User.builder()
.email("test@example.com") .email("test@example.com")
.password("password") .password("password")
@@ -84,8 +79,7 @@ class LoginServiceTest extends AbstractUnitTest {
.build(); .build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", null, Role.USER)).thenReturn("mock-token"); when(refreshTokenService.startSession(815L, dbUser.getPassword())).thenReturn(new Session("mock-token", "mock-refresh"));
when(refreshTokenService.startFamily(815L, dbUser.getPassword())).thenReturn("mock-refresh");
// Act // Act
var result = loginService.login(user, IP, null); var result = loginService.login(user, IP, null);
@@ -176,7 +170,7 @@ class LoginServiceTest extends AbstractUnitTest {
// Act & Assert // Act & Assert
assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null)); assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any()); verify(refreshTokenService, never()).startSession(anyLong(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP); verify(loginAttemptService).recordSuccess("test@example.com", IP);
} }
@@ -188,7 +182,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
assertThrows(AccountPendingException.class, () -> loginService.login(user, IP, null)); assertThrows(AccountPendingException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any()); verify(refreshTokenService, never()).startSession(anyLong(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP); verify(loginAttemptService).recordSuccess("test@example.com", IP);
} }
@@ -198,7 +192,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any()); verify(refreshTokenService, never()).startSession(anyLong(), any());
} }
@Test @Test
@@ -220,18 +214,6 @@ class LoginServiceTest extends AbstractUnitTest {
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
} }
@Test
void login_TheTokenCarriesTheStoredRole() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
.role(Role.PLATFORM_ADMIN).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
loginService.login(user, IP, null);
verify(tokenGenerator).generateToken("815", null, Role.PLATFORM_ADMIN);
}
@Test @Test
void login_TooManyAttempts() { void login_TooManyAttempts() {
// Arrange // Arrange
@@ -252,6 +234,6 @@ class LoginServiceTest extends AbstractUnitTest {
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(refreshTokenService, never()).startFamily(anyLong(), any()); verify(refreshTokenService, never()).startSession(anyLong(), any());
} }
} }
@@ -0,0 +1,47 @@
package auth_service.modules.profile.service;
import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.repo.PublicProfileRepository;
import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PublicProfileServiceTest extends AbstractUnitTest {
@Mock
private PublicProfileRepository publicProfileRepository;
@Mock
private ProfileTicketIssuer profileTicketIssuer;
@InjectMocks
private PublicProfileService service;
@Test
void findPublicProfile_TicketCarriesTheSectionsAndTheStatGroups() {
PublicProfile profile = new PublicProfile(7L, Role.USER, "lobo", null,
new PublicProfile.Style(null, null, null, null, Map.of()),
List.of(new PublicProfile.Tile("SUMMARY", "M"), new PublicProfile.Tile("STATS_BACKLOG", "M"),
new PublicProfile.Tile("STATS_PLAYTIME", "S")));
when(publicProfileRepository.findPublicByDisplayName("lobo")).thenReturn(Optional.of(profile));
when(profileTicketIssuer.generateProfileTicket(anyLong(), any(), anyList(), anyList())).thenReturn("ticket");
service.findPublicProfile("lobo");
verify(profileTicketIssuer).generateProfileTicket(7L, Role.USER, List.of("BACKLOG", "PLAYTIME"),
List.of("SUMMARY", "STATS"));
}
}
@@ -0,0 +1,105 @@
package auth_service.modules.stats.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.repo.StatsRepository;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class UserListControllerTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String VIEWER = "viewer@owner.test";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private StatsRepository statsRepository;
private final JsonMapper json = JsonMapper.builder().build();
private void insertUser(String email, String name, String status, boolean enabled, boolean deleted,
boolean publicProfile, boolean optIn, Long steamId) {
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, display_name, steam_id, deleted_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? THEN now() ELSE NULL END) RETURNING id", Long.class,
email, passwordEncoder.encode(PASSWORD), enabled, status, name, steamId, deleted);
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile, show_in_owner_lists) "
+ "VALUES (?, 'en', 'dark', ?, ?)", id, publicProfile, optIn);
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM users WHERE email LIKE '%@owner.test'");
insertUser(VIEWER, "TheViewer", "ACTIVE", true, false, true, true, 76561198000000001L);
insertUser("listed@owner.test", "VisibleLobo", "ACTIVE", true, false, true, true, 76561198000000002L);
insertUser("a@owner.test", "HiddenOptOut", "ACTIVE", true, false, true, false, 76561198000000003L);
insertUser("b@owner.test", "HiddenNotPublic", "ACTIVE", true, false, false, true, 76561198000000004L);
insertUser("c@owner.test", "HiddenBlocked", "ACTIVE", false, false, true, true, 76561198000000005L);
insertUser("d@owner.test", "HiddenDeleted", "ACTIVE", true, true, true, true, 76561198000000006L);
insertUser("e@owner.test", "HiddenPending", "PENDING", true, false, true, true, 76561198000000007L);
insertUser("f@owner.test", null, "ACTIVE", true, false, true, true, 76561198000000008L);
}
private Cookie login() throws Exception {
Cookie access = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + VIEWER + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
return new Cookie(AuthCookies.ACCESS_COOKIE, access.getValue());
}
private String list(UserCountKey key) throws Exception {
return mockMvc.perform(get("/api/stats/users").param("key", key.name()).cookie(login()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
}
@Test
void withoutLogin_Answers401() throws Exception {
mockMvc.perform(get("/api/stats/users").param("key", "TOTAL_USERS")).andExpect(status().isUnauthorized());
}
@Test
void privateUsersNeverAppearByNameOrIdAndEveryoneIsCounted() throws Exception {
for (UserCountKey key : UserCountKey.values()) {
String body = list(key);
JsonNode root = json.readTree(body);
assertThat(body).doesNotContain("Hidden", "TheViewer", "@owner.test");
List<String> names = new ArrayList<>();
root.get("listed").forEach(person -> {
assertThat(person.propertyNames()).containsExactly("displayName");
names.add(person.get("displayName").asString());
});
assertThat(names).containsExactly("VisibleLobo");
assertThat(root.get("includesYou").asBoolean()).isTrue();
long expected = key == UserCountKey.TOTAL_USERS ? statsRepository.countTotalUsers()
: statsRepository.countUsersWithSteamLinked();
assertThat(root.get("listedTotal").asLong() + root.get("privateCount").asLong() + 1).isEqualTo(expected);
}
}
}
@@ -387,6 +387,33 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE")); .andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE"));
} }
@Test
void getCurrentUser_SendsShowInOwnerLists() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com")
.settings(UserSettings.builder().language("en").theme("dark").showInOwnerLists(true).build()).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.settings.showInOwnerLists").value(true));
}
@Test
void updateOwnerLists_Answers204AndPassesTheFlag_MissingEnabledIs400() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/owner-lists").contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":true}"))
.andExpect(status().isNoContent());
mockMvc.perform(put("/api/users/settings/owner-lists").contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
verify(userService).updateOwnerLists(1L, true);
}
@Test @Test
void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception { void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception {
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
@@ -475,16 +502,23 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"group\":\"FINISHED\",\"chart\":null}")) .content("{\"group\":\"FINISHED\",\"chart\":null}"))
.andExpect(status().isNoContent()); .andExpect(status().isNoContent());
mockMvc.perform(put("/api/users/settings/stat-chart")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"group\":\"ACTIVITY_GAMES\",\"chart\":\"DONUT\"}"))
.andExpect(status().isNoContent());
verify(userService).updateStatChart(1L, "PLATFORMS", "PIE"); verify(userService).updateStatChart(1L, "PLATFORMS", "PIE");
verify(userService).updateStatChart(1L, "FINISHED", null); verify(userService).updateStatChart(1L, "FINISHED", null);
verify(userService).updateStatChart(1L, "ACTIVITY_GAMES", "DONUT");
} }
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = { @ValueSource(strings = {
"{\"group\":\"TASTE\",\"chart\":\"LINE\"}", "{\"group\":\"TASTE\",\"chart\":\"LINE\"}",
"{\"group\":\"TOP_GAMES\",\"chart\":\"LIST\"}", "{\"group\":\"HABITS\",\"chart\":\"LIST\"}",
"{\"group\":\"TOP_GAMES\",\"chart\":null}", "{\"group\":\"HABITS\",\"chart\":null}",
"{\"group\":\"TOP_GAMES\",\"chart\":\"LINE\"}",
"{\"group\":\"ACTIVITY\",\"chart\":\"PIE\"}",
"{\"group\":\"UNKNOWN\",\"chart\":\"BARS\"}", "{\"group\":\"UNKNOWN\",\"chart\":\"BARS\"}",
"{\"group\":\"platforms\",\"chart\":\"PIE\"}", "{\"group\":\"platforms\",\"chart\":\"PIE\"}",
"{\"group\":\"PLATFORMS\",\"chart\":\"RADAR\"}", "{\"group\":\"PLATFORMS\",\"chart\":\"RADAR\"}",
@@ -17,13 +17,16 @@ class StatChartsTest extends AbstractUnitTest {
@ParameterizedTest @ParameterizedTest
@CsvSource({"TREND,LINE", "TREND,BARS", "FINISHED,BARS", "FINISHED,LINE", "FINISHED,LIST", "TASTE,BARS", @CsvSource({"TREND,LINE", "TREND,BARS", "FINISHED,BARS", "FINISHED,LINE", "FINISHED,LIST", "TASTE,BARS",
"TASTE,PIE", "TASTE,DONUT", "TASTE,LIST", "PLATFORMS,BARS", "PLATFORMS,PIE", "PLATFORMS,DONUT", "TASTE,PIE", "TASTE,DONUT", "TASTE,LIST", "PLATFORMS,BARS", "PLATFORMS,PIE", "PLATFORMS,DONUT",
"PLATFORMS,LIST"}) "PLATFORMS,LIST", "TOP_GAMES,LIST", "TOP_GAMES,BARS", "TOP_GAMES,PIE", "TOP_GAMES,DONUT",
"ACTIVITY,BARS", "ACTIVITY,LINE", "ACTIVITY_GAMES,LIST", "ACTIVITY_GAMES,BARS", "ACTIVITY_GAMES,PIE",
"ACTIVITY_GAMES,DONUT"})
void allows_EachAllowedPair(String group, String chart) { void allows_EachAllowedPair(String group, String chart) {
assertTrue(StatCharts.allows(group, chart)); assertTrue(StatCharts.allows(group, chart));
} }
@ParameterizedTest @ParameterizedTest
@CsvSource({"TASTE,LINE", "TOP_GAMES,LIST", "UNKNOWN,BARS", "platforms,PIE", "PLATFORMS,pie", "PLATFORMS,RADAR", @CsvSource({"TASTE,LINE", "HABITS,LIST", "TOP_GAMES,LINE", "ACTIVITY,PIE", "ACTIVITY_GAMES,LINE",
"UNKNOWN,BARS", "platforms,PIE", "PLATFORMS,pie", "PLATFORMS,RADAR",
"TREND,PIE"}) "TREND,PIE"})
void allows_OtherPairs_AreRefused(String group, String chart) { void allows_OtherPairs_AreRefused(String group, String chart) {
assertFalse(StatCharts.allows(group, chart)); assertFalse(StatCharts.allows(group, chart));
@@ -32,7 +35,9 @@ class StatChartsTest extends AbstractUnitTest {
@Test @Test
void knows_OnlyTheChartableGroups() { void knows_OnlyTheChartableGroups() {
assertTrue(StatCharts.knows("PLATFORMS")); assertTrue(StatCharts.knows("PLATFORMS"));
assertFalse(StatCharts.knows("TOP_GAMES")); assertTrue(StatCharts.knows("TOP_GAMES"));
assertTrue(StatCharts.knows("ACTIVITY_GAMES"));
assertFalse(StatCharts.knows("HABITS"));
assertFalse(StatCharts.knows("platforms")); assertFalse(StatCharts.knows("platforms"));
} }
@@ -253,6 +253,25 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile()); assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
} }
@Test
void showInOwnerLists_IsOffForANewUserAndRoundTrips() {
var userEntity = persisted(UserEntity.builder()
.email("owners@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
entityManager.clear();
assertFalse(findByEmail("owners@example.com").orElseThrow().getSettings().isShowInOwnerLists());
userRepository.update(userEntity.getId(), user -> user.getSettings().setShowInOwnerLists(true));
entityManager.flush();
entityManager.clear();
assertTrue(findByEmail("owners@example.com").orElseThrow().getSettings().isShowInOwnerLists());
}
@Test @Test
void updateSettings_KeepsTheAppearance() { void updateSettings_KeepsTheAppearance() {
var userEntity = persisted(UserEntity.builder() var userEntity = persisted(UserEntity.builder()
@@ -77,6 +77,13 @@ class UserServiceTest extends AbstractUnitTest {
assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles()); assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles());
} }
@Test
void updateProfileTiles_PublishesTheAccountStateOnce() {
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
verify(accountStatePublisher, times(1)).publish(1L);
}
@Test @Test
void updateStatChart_PutsTheFormOfTheGroup() { void updateStatChart_PutsTheFormOfTheGroup() {
userService.updateStatChart(1L, "PLATFORMS", "PIE"); userService.updateStatChart(1L, "PLATFORMS", "PIE");
@@ -153,7 +160,7 @@ class UserServiceTest extends AbstractUnitTest {
@Test @Test
void updateUserSettings_UnchangedDisplayName_PublishesNothing() { void updateUserSettings_UnchangedDisplayName_PublishesNothing() {
var userSettings = UserSettings.builder().language("en").build(); var userSettings = UserSettings.builder().language("en").build();
when(userRepository.findById(1L)).thenReturn(Optional.of(User.builder().id(1L).displayName("Lobo").build())); when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", false)));
userService.updateUserSettings(1L, "Lobo", userSettings); userService.updateUserSettings(1L, "Lobo", userSettings);
@@ -161,6 +168,60 @@ class UserServiceTest extends AbstractUnitTest {
verifyNoInteractions(accountStatePublisher); verifyNoInteractions(accountStatePublisher);
} }
private static User stored(String displayName, boolean publicProfile) {
return User.builder().id(1L).displayName(displayName)
.settings(UserSettings.builder().publicProfile(publicProfile).build()).build();
}
@Test
void updateUserSettings_PublicProfileChange_Publishes() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().language("en").publicProfile(false).build());
verify(accountStatePublisher).publish(1L);
}
@Test
void updateUserSettings_NameRemoval_Publishes() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", false)));
userService.updateUserSettings(1L, null, UserSettings.builder().language("en").build());
verify(accountStatePublisher).publish(1L);
}
@Test
void updateUserSettings_ThemeOnly_PublishesNothing() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().theme("light").publicProfile(true).build());
verifyNoInteractions(accountStatePublisher);
}
@Test
@SuppressWarnings("unchecked")
void updateUserSettings_KeepsShowInOwnerLists() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().publicProfile(true).build());
ArgumentCaptor<Consumer<User>> change = ArgumentCaptor.forClass(Consumer.class);
verify(userRepository).update(eq(1L), change.capture());
User user = User.builder().id(1L).settings(UserSettings.builder().showInOwnerLists(true).build()).build();
change.getValue().accept(user);
assertTrue(user.getSettings().isShowInOwnerLists());
}
@Test
void updateOwnerLists_StoresAndPublishes() {
userService.updateOwnerLists(1L, true);
assertTrue(appliedChanges(1).getFirst().getSettings().isShowInOwnerLists());
verify(accountStatePublisher).publish(1L);
}
private static final long STEAM_ID = 76561198000000001L; private static final long STEAM_ID = 76561198000000001L;
@Test @Test
@@ -0,0 +1,32 @@
package auth_service.shared.account;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ProfileSectionsTest extends AbstractUnitTest {
@Test
void of_KeepsThePartsInOrder() {
assertThat(ProfileSections.of(List.of("SUMMARY", "PLAYING", "FINISHED"))).containsExactly("SUMMARY", "PLAYING", "FINISHED");
}
@Test
void of_CollapsesEveryStatsTileToOneStatsSectionAtTheEnd() {
assertThat(ProfileSections.of(List.of("STATS_A", "SUMMARY", "STATS_B"))).containsExactly("SUMMARY", "STATS");
}
@Test
void of_NoTilesNoSections() {
assertThat(ProfileSections.of(List.of())).isEmpty();
}
@Test
void of_DropsTileIdsThatCoreDoesNotKnow() {
assertThat(ProfileSections.of(List.of("SUMMARY", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "OWN_TILE", "STATS")))
.containsExactly("SUMMARY", "STATS");
}
}
@@ -0,0 +1,42 @@
package auth_service.shared.security;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class UserTokenStateTest extends AbstractUnitTest {
private static final Instant CHANGE = Instant.parse("2026-10-04T12:00:00Z");
@Test
void activeWithoutRoleChange_AcceptsAnyToken() {
assertTrue(new UserTokenState(true, null).accepts(CHANGE));
assertTrue(new UserTokenState(true, null).accepts(null));
}
@Test
void inactive_RefusesEveryToken() {
assertFalse(UserTokenState.INACTIVE.accepts(CHANGE));
assertFalse(new UserTokenState(false, CHANGE).accepts(CHANGE.plusSeconds(60)));
}
@Test
void tokenFromBeforeTheRoleChange_IsRefused() {
assertFalse(new UserTokenState(true, CHANGE).accepts(CHANGE.minusSeconds(1)));
}
@Test
void tokenFromTheChangeSecondOrLater_IsAccepted() {
assertTrue(new UserTokenState(true, CHANGE).accepts(CHANGE));
assertTrue(new UserTokenState(true, CHANGE).accepts(CHANGE.plusSeconds(1)));
}
@Test
void tokenWithoutIssueTime_IsRefusedAfterARoleChange() {
assertFalse(new UserTokenState(true, CHANGE).accepts(null));
}
}
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]} "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true, "publicProfile": true, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
}, },
"replaceStates": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]} "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false, "publicProfile": false, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
}, },
"purge": { "purge": {
"method": "POST", "method": "POST",