192 lines
11 KiB
Markdown
192 lines
11 KiB
Markdown
---
|
||
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": "<jwt>",
|
||
"expiresIn": 900,
|
||
"user": { "id": "<uuid>", "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": "<uuid>",
|
||
"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)
|