11 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| api | Auth Endpoints | Login, refresh, logout, public self-registration, CSRF, /me, change-password, first-admin registration, and registration throttling. |
|
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)
{
"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 singleDataSource.transaction:RegistrationRateLimitService.tryConsume(ip)permits at most 10 attempts per IP in a rolling 60-second window.SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')check.- Username uniqueness lookup →
USERNAME_TAKENon collision. - Argon2id hash + insert
UserEntity({ role: 'player', status: 'enabled' }). 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)
{
"id": "<uuid>",
"username": "player1",
"role": "player",
"rank": 3,
"points": 275
}
rankisnullwhile the user has 0 points (no rank assigned).pointsisSUM(solve.pointsAwarded)for that user.rankis1 + (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:
- Validate
newPassword === confirmNewPassword→PASSWORDS_DO_NOT_MATCH. - Reject
oldPassword === newPassword→PASSWORD_POLICY. argon2.verify(user.passwordHash, dto.oldPassword)→INVALID_OLD_PASSWORDon miss.validatePassword(dto.newPassword, config)→ rethrown asPASSWORD_POLICYon failure.- Re-hash with Argon2id and
manager.save(user). - UPDATE all
refresh_tokenrows for the user whererevokedAt 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 consumes
changePassword() and maps failures via
formatChangePasswordError() in
frontend/src/app/features/shell/change-password/password-feedback.ts.
Examples
Tester flow: registration rate limit
- Enable registrations in the admin settings.
- Open the public registration form from the landing page.
- Submit attempts from the same client IP.
- The first 10 attempts are processed normally; the 11th within 60 seconds returns HTTP
429with codeRATE_LIMITED. - After the rolling window expires, a new attempt is accepted if other requirements pass.
Tester flow: change-password happy path
- Sign in via
/login. - Click the username dropdown in the shell header → "Change password".
- Enter the current password, a new password that meets the policy, and the same new password in "Confirm new password".
- Click OK. The modal closes and the success indicator (modal closes) is visible.
- The user's existing refresh tokens have been revoked, so a
refreshfrom any other tab will return 401 and that tab will be sent to/loginon the next guard tick.