Public Access
Compare commits
9
Commits
3410cbc598
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4701cebece | ||
|
|
c124d0e5d2 | ||
|
|
c362e7047e | ||
|
|
734ff2e07c | ||
|
|
d842d9e865 | ||
|
|
d924c09953 | ||
|
|
d9b816904a | ||
|
|
395b8e6f99 | ||
|
|
365b69feb4 |
No files matched your search
@@ -1,22 +0,0 @@
|
|||||||
# docker-compose.dev.yml
|
|
||||||
services:
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# LOCAL DATABASE FOR DEVELOPMENT
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
tbp-db-local:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
container_name: tbp_db_local
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
# Map the container port 5432 to your local machine port 5432
|
|
||||||
- "5432:5432"
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=tbp_auth
|
|
||||||
- POSTGRES_USER=tbp_user
|
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
# Use a local volume to persist data across container restarts
|
|
||||||
- tbp_local_db_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
tbp_local_db_data:
|
|
||||||
@@ -86,11 +86,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.micrometer</groupId>
|
|
||||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- testing -->
|
<!-- testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,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 {
|
||||||
|
// 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)));
|
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
|
||||||
|
}
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
|
if (attempt < RETRY_DELAYS.size()) {
|
||||||
|
log.warn("Core did not take the state of user {} (try {}), trying again later: {}", userId,
|
||||||
|
attempt + 1, e.getMessage());
|
||||||
|
scheduler.schedule(() -> executor.execute(() -> send(userId, attempt + 1)),
|
||||||
|
Instant.now().plus(RETRY_DELAYS.get(attempt)));
|
||||||
|
} else {
|
||||||
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
|
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
|
||||||
e.getMessage());
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-2
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user