--- type: api title: Auth Endpoints description: Login, refresh, logout, public self-registration, CSRF, /me, change-password, first-admin registration, and registration throttling. tags: [api, auth, login, register, refresh, csrf, me, change-password] timestamp: 2026-07-21T22:19:08Z --- # Endpoints | Method | Path | Auth | CSRF | Rate limited | Source | |--------|-----------------------------------|--------|-----------------------------------------------|--------------|--------| | `POST` | `/api/v1/auth/register` | `@Public()` | Enforced — caller must first call `GET /api/v1/auth/csrf` (the SPA's `AuthService.ensureCsrf()` does this automatically). | Yes — `RegistrationRateLimitService` per IP | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/login` | `@Public()` | Enforced — same pattern as `register`. CSRF skip was removed in this change. | Yes — `LoginBackoffService` per IP + username | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/refresh` | Public route, requires a valid refresh cookie. | Skipped (the request is a `POST` carrying an HttpOnly cookie, not a state-changing user action). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/logout` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` | | `GET` | `/api/v1/auth/me` | Authenticated (JWT). | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/change-password` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` | | `GET` | `/api/v1/auth/csrf` | `@Public()` | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/register-first-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`). | Yes | `backend/src/modules/users/users.controller.ts` | # Public player self-registration — `POST /api/v1/auth/register` New endpoint, gated by the `registrationsEnabled` setting (`backend/src/config/env.schema.ts` → `SETTINGS_KEYS.REGISTRATIONS_ENABLED`, default `"false"`). | Field | Type | Constraints | |-------------------|--------|------------------------------------------------------------------------------| | `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` | | `password` | string | 1–256 chars; also passes `validatePassword` (Argon2 policy). | | `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED` on `passwordConfirm`). | Validated by `backend/src/modules/auth/dto/auth.dto.ts` → `RegisterDtoSchema`. ## Successful response (201) ```json { "accessToken": "", "expiresIn": 900, "user": { "id": "", "username": "player1", "role": "player" } } ``` The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, session.refreshToken, config)`. The new `user` row has `role='player'` and `status='enabled'`. ## Error envelope | HTTP | `code` | When | |------|-------------------------|---------------------------------------------------------------------------------| | 400 | `VALIDATION_FAILED` | zod body validation failed. | | 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password. | | 403 | `REGISTRATIONS_DISABLED`| `setting.registrationsEnabled !== 'true'`. | | 409 | `USERNAME_TAKEN` | A user with that username already exists. | | 429 | `RATE_LIMITED` | The per-IP registration limiter has reached 10 attempts in the rolling 60-second window. | ## Wiring * `AuthService.registerPlayer(dto, ip)` runs inside a single `DataSource.transaction`: 1. `RegistrationRateLimitService.tryConsume(ip)` permits at most 10 attempts per IP in a rolling 60-second window. 2. `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')` check. 3. Username uniqueness lookup → `USERNAME_TAKEN` on collision. 4. Argon2id hash + insert `UserEntity({ role: 'player', status: 'enabled' })`. 5. `mintSession(manager, user)` → access JWT + refresh row. * `AuthService.registerFirstAdmin(username, password, ip)` uses the same limiter before checking whether an administrator already exists. * The limiter is an in-memory Nest provider in `backend/src/common/services/registration-rate-limit.service.ts`; counters are process-local and reset when the API process restarts. # `GET /api/v1/auth/me` Authenticated-only projection of the current user, including derived rank and total points from the `solve` table. | HTTP | `code` | When | |------|-----------------|-----------------------------------------------------| | 200 | — | Returns `{ id, username, role, rank, points }`. | | 401 | `UNAUTHORIZED` | No JWT on the request (handled by `JwtAuthGuard`). | ## Successful response (200) ```json { "id": "", "username": "player1", "role": "player", "rank": 3, "points": 275 } ``` * `rank` is `null` while the user has 0 points (no rank assigned). * `points` is `SUM(solve.pointsAwarded)` for that user. * `rank` is `1 + (count of users with strictly higher points total)`. ## Wiring `AuthService.getMe(userId)` (`backend/src/modules/auth/auth.service.ts`) loads the `UserEntity` then delegates the rank/points math to `UsersRankService.rankOfUser(manager, userId)` (`backend/src/modules/users/users-rank.service.ts`). # `POST /api/v1/auth/change-password` Authenticated-only password change. Requires the current password (`mode='self'`); the admin-reset variant lives in a later Job. | Field | Type | Constraints | |-----------------------|--------|-----------------------------------------------------------------------------------| | `oldPassword` | string | 1–256 chars. Required when `mode='self'`. | | `newPassword` | string | 1–256 chars; passes `validatePassword` (Argon2 policy) AND must differ from old. | | `confirmNewPassword` | string | Must equal `newPassword` (zod refine → `VALIDATION_FAILED`). | Validated by `ChangePasswordDtoSchema` in `backend/src/modules/auth/dto/auth.dto.ts`. ## Successful response (204) No body. The new password hash is persisted inside a `DataSource.transaction` that also revokes every active `refresh_token` row for the user (`revokedAt = now`), forcing the user to re-authenticate on other devices. ## Error envelope | HTTP | `code` | When | |------|---------------------------|----------------------------------------------------------------------------------------| | 400 | `VALIDATION_FAILED` | zod body validation failed. | | 400 | `PASSWORDS_DO_NOT_MATCH` | `newPassword !== confirmNewPassword`. | | 400 | `PASSWORD_POLICY` | New password equals the old, or fails the Argon2 policy (`PASSWORD_MIN_LENGTH`, mixed-case requirement). | | 401 | `UNAUTHORIZED` | No JWT on the request. | | 401 | `INVALID_OLD_PASSWORD` | `argon2.verify(oldPasswordHash, oldPassword)` failed. | ## Wiring `AuthService.changePassword(userId, dto)` runs inside a single `DataSource.transaction`: 1. Validate `newPassword === confirmNewPassword` → `PASSWORDS_DO_NOT_MATCH`. 2. Reject `oldPassword === newPassword` → `PASSWORD_POLICY`. 3. `argon2.verify(user.passwordHash, dto.oldPassword)` → `INVALID_OLD_PASSWORD` on miss. 4. `validatePassword(dto.newPassword, config)` → rethrown as `PASSWORD_POLICY` on failure. 5. Re-hash with Argon2id and `manager.save(user)`. 6. UPDATE all `refresh_token` rows for the user where `revokedAt IS NULL`. # Key Files | File | Responsibility | |------|----------------| | `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, mints sessions, exposes `getMe()` and `changePassword()`. | | `backend/src/common/services/registration-rate-limit.service.ts` | Tracks timestamps and enforces the 10-per-minute per-IP limit. | | `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes plus authenticated `/me` and `/change-password`. | | `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration, login, refresh, `/me`, and `change-password` request fields. | | `backend/src/modules/users/users-rank.service.ts` | Pure provider computing `{ rank, points }` over the `solve` table for a user. | | `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` (now includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`). | # Frontend consumer `frontend/src/app/core/services/auth.service.ts` exposes `login()`, `register()`, `me()`, `changePassword()`, `logout()`, and `restoreSession()`. Each writes through the existing CSRF + auth interceptors and returns a discriminated result type (`LoginResult`, `RegisterResult`, `ChangePasswordResult`) for component-level error handling. The authenticated shell's [change-password modal](/guides/change-password.md) consumes `changePassword()` and maps failures via `formatChangePasswordError()` in `frontend/src/app/features/shell/change-password/password-feedback.ts`. # Examples ## Tester flow: registration rate limit 1. Enable registrations in the admin settings. 2. Open the public registration form from the landing page. 3. Submit attempts from the same client IP. 4. The first 10 attempts are processed normally; the 11th within 60 seconds returns HTTP `429` with code `RATE_LIMITED`. 5. After the rolling window expires, a new attempt is accepted if other requirements pass. ## Tester flow: change-password happy path 1. Sign in via `/login`. 2. Click the username dropdown in the shell header → "Change password". 3. Enter the current password, a new password that meets the policy, and the same new password in "Confirm new password". 4. Click **OK**. The modal closes and the success indicator (modal closes) is visible. 5. The user's existing refresh tokens have been revoked, so a `refresh` from any other tab will return 401 and that tab will be sent to `/login` on the next guard tick. # See also - [REST API Overview](/api/rest-overview.md) - [Setup Endpoint](/api/setup.md) - [Landing Page Guide](/guides/landing-page.md) - [Authenticated Shell Guide](/guides/authenticated-shell.md) - [Change Password Guide](/guides/change-password.md)