Files
ToBePlayed-Auth/readme.md
T
lobo 2ba0d99148
Build, Test & Push Auth Service / quality-check (push) Successful in 39s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s
feat(admin): add roles, view as and the admin users API
users.role (USER < AUDITOR < PLATFORM_ADMIN) goes into the access token as the active role. POST /api/auth/view-as lowers the own login and is kept per refresh token, never above the stored role. /api/admin/users lists, blocks and deletes users; every action is written to admin_audit. A blocked login answers 403 ACCOUNT_BLOCKED, only after the right password.
2026-09-27 12:18:10 +02:00

13 KiB
Raw Blame History

To Be Played – Auth Service

Identity service of To Be Played, a Steam backlog tracker. It handles registration, login, user settings and the public profile, and it signs the JWTs that the Core service trusts.

To Be Played is split into three repositories:

Repository Role
ToBePlayed-Auth (this one) Users, login, settings, signs JWTs
ToBePlayed-Core Steam library, backlog, wishlist, stats; verifies JWTs
ToBePlayed-Frontend React single-page app

Features

  • Registration with an invitation code, login with email and password (BCrypt).
  • Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP, block for 15 minutes with 429 TOO_MANY_ATTEMPTS and Retry-After. Unknown emails cost the same BCrypt time as a wrong password, and every failed login gets the same error.
  • JWT signed with EdDSA (Ed25519). Auth holds the private key, Core only the public key, so Core can verify tokens but never create them. Tokens carry a kid header, and GET /.well-known/jwks.json publishes the public key as a JWK, so Core finds the right key after a rotation.
  • Session in two HttpOnly cookies, so no script on the page can read a token. The access token lives 15 minutes in tbp_access (sent to Auth and Core). The refresh token is random, lives 30 days from its last use in tbp_refresh (sent to /api/auth only) and is stored only as a SHA-256 hash. Each refresh replaces it; a replaced token that comes back revokes all tokens of that login. Logout revokes them too.
  • CSRF guard: POST, PUT and DELETE need an X-TBP-Client or Authorization header, and CORS allows only the frontend origin. A Bearer header still works for API clients and tests.
  • User settings: display name (unique, case-insensitive), language, dark mode.
  • Steam account linked through "Sign in through Steam" (Steam OpenID 2.0, no API key), never typed in. Steam proves the SteamID64, and Auth asks Steam to confirm the answer (check_authentication) before it stores the ID as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but unverified: a verified link from another account takes them away. Unlinking is possible.
  • Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
  • Opt-in public profile at /u/<display name>. Auth answers with a short-lived profile ticket (EdDSA JWT, 10 minutes, typ=public-profile) that Core accepts for the public library data only. Its profileStats claim names the stat groups the user chose to show there (none by default). The ticket is not a login: both services reject every token with a typ claim on normal endpoints.
  • Roles: one per user, a ladder USER < AUDITOR < PLATFORM_ADMIN (users.role, the first admin is set by SQL). The access token carries the active role in the role claim. View as: an admin switches the own login down to USER or AUDITOR and back (POST /api/auth/view-as); the choice is stored per login in refresh_tokens.active_role and never goes above the stored role, so a demoted admin loses the rights at the next refresh. Core does not check roles yet.
  • Admin users page (/api/admin/**, active role PLATFORM_ADMIN): search and page all users, block and unblock (users.enabled; blocking ends every login, the login then answers 403 ACCOUNT_BLOCKED, but only after the 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.
  • Delete my account with the password (5 wrong tries per 15 minutes, then 429). It only sets users.deleted_at and revokes every refresh token: from then on no login, no refresh, no public profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a new Steam link. A nightly job (04:15, Europe/Berlin) removes users flagged 30 days ago, in Core first and then in Auth. It calls Core's /internal/accounts/* with a service token (EdDSA JWT, 60 s, typ=service). app.account-cleanup.dry-run (default true) only logs.

API

Method Path Auth
POST /api/registration/new public
POST /api/auth/login public, sets both cookies
POST /api/auth/refresh refresh cookie, replaces both cookies
POST /api/auth/logout refresh cookie, clears both cookies
GET /api/users/me access token
PUT /api/users/settings access token
POST /api/users/steam-link access token, returns Steam's login URL and sets the binding cookie
DELETE /api/users/steam-link access token, removes the Steam ID and sets a new access cookie
POST /api/auth/view-as ({role}, null = own role; new cookies; 403 ROLE_NOT_ALLOWED above the own role) refresh cookie
GET /api/admin/users?query=&page=&size= ({content, page, size, totalElements, totalPages}) PLATFORM_ADMIN
PUT /api/admin/users/{id}/blocked ({blocked}; 409 SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED) PLATFORM_ADMIN
DELETE /api/admin/users/{id} (409 SELF_ACTION, TARGET_IS_ADMIN) PLATFORM_ADMIN
DELETE /api/users/me ({password}; 204 and both cookies cleared, 403 INVALID_PASSWORD, 429) access token
GET /api/steam-link/callback public, Steam's redirect back; binding cookie; redirects to the frontend settings
PUT /api/users/settings/appearance access token
PUT /api/users/settings/profile-stats ({groups}: PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND; 204) access token
GET /api/public/profiles/{displayName} public
GET /api/stats/public 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

  • Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
  • PostgreSQL 17, Flyway migrations
  • JJWT with Ed25519 keys
  • Spring Boot Actuator + Micrometer (Prometheus)
  • Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
  • CI: Forgejo Actions, multi-stage Docker image

Architecture

Package by feature (modules/login, modules/registration, modules/user, ...), and inside each feature three layers:

Layer Package Rule
API api REST controllers, request/response records, ApiMapper. Calls services, never repositories.
Domain model, service, exception Plain Java models and business logic. Uses repositories, never API classes, entities or EntityMapper.
Persistence persistence JPA entities, Spring Data repositories, EntityMapper. Entities never reach the API layer.

profile and stats only read data. They have no entities and use JdbcTemplate queries in a repo package. version has only an api package: GET /api/version answers the commit and build time of the running image. account (delete and nightly cleanup) sets the flag with JdbcTemplate in a repo package too, and calls Core through CoreAccountService. admin (users page, audit) works the same way. It deletes through the port shared/account/AccountDeleter, and the login module writes "view as" to the audit table through shared/audit/AdminAuditLog, because modules never use each other.

ArchUnit enforces these rules in src/test/java/auth_service/architecture/ArchitectureTest.java, so a layer violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base classes (AbstractUnitTest, AbstractWebMvcTest, AbstractPersistenceTest, AbstractIntegrationTest) live in auth_service.shared.test; all integration tests share one PostgreSQL container.

Login flow

sequenceDiagram
    participant B as Browser (Frontend)
    participant A as Auth
    participant C as Core
    B->>A: POST /api/auth/login
    A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
    B->>C: API call with tbp_access
    C->>A: GET /.well-known/jwks.json (new kid only)
    A-->>C: Public keys (Ed25519)
    C-->>B: 200, or 401 when the access token expired
    B->>A: POST /api/auth/refresh with tbp_refresh
    A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
    B->>C: Same API call again
    Note over B,A: View as (admin only)
    B->>A: POST /api/auth/view-as {role} with tbp_refresh
    A-->>B: New tbp_access (role claim) and tbp_refresh, active role stored for this login
  1. LoginService checks the password. LoginController sets both HttpOnly cookies (AuthCookies), so no script can read them.
  2. Core verifies the access token with Auth's public keys only. JwksController publishes them; Core loads them again only for an unknown kid.
  3. On a 401 the frontend calls POST /api/auth/refresh once and sends the request again. RefreshTokenService replaces the refresh token. A replaced token that comes back revokes the whole login.
sequenceDiagram
    participant B as Browser (Frontend)
    participant A as Auth
    participant S as Steam
    B->>A: POST /api/users/steam-link (logged in)
    A-->>B: Steam login URL, Set-Cookie tbp_steam_link (state, 10 min)
    B->>S: Sign in on steamcommunity.com
    S-->>B: Redirect to /api/steam-link/callback?state=...&openid.*
    B->>A: GET callback with tbp_steam_link
    A->>S: check_authentication (openid.* sent back)
    S-->>A: is_valid:true
    A-->>B: Redirect to /settings?steam=linked
    B->>A: POST /api/auth/refresh (new steamId claim for Core)

SteamLinkService checks the state against the binding cookie before it asks Steam anything, and uses each state once. SteamOpenIdService checks mode, endpoint, return_to and the signed fields, then asks Steam. UserService.linkVerifiedSteamId stores the ID and takes it from an account that only typed it in.

Delete account flow

sequenceDiagram
    participant B as Browser (Frontend)
    participant A as Auth
    participant C as Core
    B->>A: DELETE /api/users/me {password}
    A->>A: users.deleted_at = now, revoke refresh tokens
    A->>C: POST /internal/accounts/deleted (service token)
    A-->>B: 204, both cookies cleared
    Note over A,C: nightly 04:15
    A->>C: PUT /internal/accounts/deleted (all flagged users)
    A->>C: POST /internal/accounts/purge (flagged 30+ days)
    A->>A: DELETE users (only when Core answered and no dry run)
    A->>C: POST /internal/accounts/prune (all user ids)

A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30 days is UPDATE users SET deleted_at = NULL (see the Deployment README).

Run locally

Needs Java 25 and Docker.

./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

Spring Boot's Docker Compose support starts PostgreSQL from compose.yaml, Flyway applies the migrations, and the service listens on http://localhost:8080. Health: /actuator/health, metrics: /actuator/prometheus.

The cookies are host-only unless APP_AUTH_COOKIE_DOMAIN is set; production sets it to the parent domain, so Core gets the access cookie too. Browsers accept Secure cookies on http://localhost, so local development needs no cookie settings. Open the frontend on localhost (not 127.0.0.1), so the browser sends the cookies to every local port.

Configuration comes from environment variables:

Variable Purpose
JWT_PRIVATE_KEY, JWT_PUBLIC_KEY Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509)
JWT_KID Key id written into the token header
AUTH_INVITATION_CODE Code required for registration
DB_PASSWORD Local PostgreSQL password
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)

Generate a keypair:

openssl genpkey -algorithm ed25519 -out private.pem
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

Tests

./mvnw clean verify

Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.

Roadmap

  • Account deletion and data export.

License

All rights reserved. The source is public for reading only.