Files
HIPCTF2/docs/api/auth.md
T
2026-07-21 22:26:44 +00:00

11 KiB
Raw Blame History

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.
api
auth
login
register
refresh
csrf
me
change-password
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.tsSETTINGS_KEYS.REGISTRATIONS_ENABLED, default "false").

Field Type Constraints
username string 332 chars, regex ^[a-zA-Z0-9_.-]+$
password string 1256 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.tsRegisterDtoSchema.

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 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)

{
  "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 1256 chars. Required when mode='self'.
newPassword string 1256 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 === confirmNewPasswordPASSWORDS_DO_NOT_MATCH.
  2. Reject oldPassword === newPasswordPASSWORD_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 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