Skip to content

Flow: dropbet password reset

Trace IDs: 20978d38f58fdac55b0a491cfadc78a8 (forgot-password) · 58b349ccf8cb66d0847b3f833e089707 (confirm-password-reset) Jaeger: http://localhost:16686/trace/20978d38f58fdac55b0a491cfadc78a8 · http://localhost:16686/trace/58b349ccf8cb66d0847b3f833e089707 · E2E: tests-e2e/tests/dropbet-password-reset.spec.ts Generated: 2026-04-16 · Services touched: ebit-api

1. User-visible contract

  • Step 1 — request token. POST http://localhost:4000/user/forgot-password with { userEmail } and header x-captcha-token: pass (local bypass at ebit-api/apps/api/src/captcha/google/recaptcha.service.ts:28). Returns 201 with { result: "Password reset email sent" } whether or not the email matches a user (user.service.ts:867-869 silent-branch on miss — anti-enumeration).
  • Step 2 — submit new password. PATCH http://localhost:4000/user/confirm-password-reset with { token, newPassword }. No captcha header. Returns 200 with { result: "Password has been reset" }, or 400 USER_PASSWORD_RESET_INVALID_TOKEN / USER_PASSWORD_VALIDATION_FAILED.
  • Rate limit. 40 s per-email cooldown (user.service.ts:970-982, const PASSWORD_RESET_COOLDOWN_DURATION=40 at apps/api/src/user/const.ts:24). A repeat inside the window returns 400 USER_PASSWORD_RESET_COOLDOWN.
  • Side effects on success. Only the user.password column is written. Cache keys user:login-attempts:<username> and user:lockout:<username> are cleared (user.service.ts:913-916). Auth sessions are not invalidated — existing access_token / refresh_token cookies remain valid.
  • Email. In local (isLocal) the mailer short-circuits at email-sender.service.ts:63-65 — nothing is sent, nothing is logged. The test therefore forges the JWT client-side with the shared JWT_VERIFICATION_TOKEN_SECRET (documented sharp edge, §6-1).

2. Sequence diagram

sequenceDiagram
  participant U as Browser
  participant API as ebit-api
  participant R as Redis (cache)
  participant PG as Postgres
  participant M as EmailSenderService

  Note over U,M: Step 1 — forgot-password
  U->>API: POST /user/forgot-password { userEmail }
  API->>API: ThrottleRecaptcha → recaptcha.service.ts:28 ('pass' bypass)
  API->>PG: findFirstUser({email}) — 6 SELECTs (user + 5 relations)
  API->>R: GET user:password-reset-cooldown:<email>
  API->>API: jwtService.sign({userId}, HS256, exp=1200s)
  API->>R: SET user:password-reset-cooldown:<email> 40
  API->>M: sendEmail('profile','resetPassword', {token,email,username})
  M-->>API: (isLocal → returns undefined, no egress)
  API-->>U: 201 { result: "Password reset email sent" }

  Note over U,M: Step 2 — confirm-password-reset
  U->>API: PATCH /user/confirm-password-reset { token, newPassword }
  API->>R: WaitMutex SET user:reset-password:<sha256(token)> EX
  API->>API: jwt.verify(token, JWT_VERIFICATION_TOKEN_SECRET)
  API->>PG: findUniqueUser(payload.userId)
  API->>API: validateAndHashPassword(newPassword) — joi + bcrypt.hash
  API->>PG: BEGIN, UPDATE user SET password, SELECT relations, COMMIT
  API->>R: DEL user:login-attempts:<username>
  API->>R: DEL user:lockout:<username>
  API->>R: WaitMutex DEL user:reset-password:<sha256(token)> + PUBLISH
  API-->>U: 200 { result: "Password has been reset" }

3. Component diagram

Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through.

flowchart TD
    %% Datastores
    pg[("Postgres<br/>user · user_role · user_kyc · user_promo_code · user_permission · user_note")]
    rd[("Redis (cache)<br/>password-reset-cooldown · reset-password mutex · login-attempts · lockout")]

    %% Browser
    subgraph web["Browser (Playwright E2E)"]
        client["fetch()<br/><i>POST /user/forgot-password · PATCH /user/confirm-password-reset</i>"]
    end

    %% ebit-api process
    subgraph api["ebit-api (NestJS)"]
        ctl["UserController<br/><i>sentForgotPasswordEmail · resetUserPassword</i>"]
        cap["ThrottleRecaptcha + RecaptchaService<br/><i>isLocal 'pass' bypass</i>"]
        svc1["UserService.resendPasswordResetEmail<br/><i>silent-branch on miss</i>"]
        svc2["UserService.resetUserPassword<br/><i>@WaitMutex(sha256(token))</i>"]
        jwt["JwtService<br/><i>HS256 · JWT_VERIFICATION_TOKEN_SECRET</i>"]
        mail["EmailSenderService<br/><i>sendEmail (isLocal no-op)</i>"]
        valid["validateAndHashPassword<br/><i>joi complexity + bcrypt.hash(10)</i>"]
    end

    %% (1)-(7) Step 1 — forgot-password
    client -- "(1) POST /user/forgot-password" --> ctl
    ctl -- "(2) guard chain" --> cap
    ctl -- "(3) resendPasswordResetEmail" --> svc1
    svc1 -- "(4) findFirstUser(email)" --> pg
    svc1 -- "(5) GET/SET cooldown (40s TTL)" --> rd
    svc1 -- "(6) signAsync({userId}, 1200s)" --> jwt
    svc1 -- "(7) sendEmail (isLocal no-op)" --> mail

    %% (8)-(14) Step 2 — confirm-password-reset
    client -- "(8) PATCH /user/confirm-password-reset" --> ctl
    ctl -- "(9) resetUserPassword" --> svc2
    svc2 -- "(10) @WaitMutex SET/DEL + PUBLISH" --> rd
    svc2 -- "(11) jwt.verify(token)" --> jwt
    svc2 -- "(12) findUnique + UPDATE user.password" --> pg
    svc2 -- "(13) validateAndHashPassword" --> valid
    svc2 -- "(14) DEL login-attempts + lockout" --> rd

    %% Style: datastores stand out
    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd db;

4. Per-step walkthrough

Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. The two endpoints split the numbering: steps (1)–(7) belong to POST /user/forgot-password (28 ms · 35 spans, root trace 20978d38…), steps (8)–(14) belong to PATCH /user/confirm-password-reset (104 ms · 42 spans, root trace 58b349cc…).

4.1 Step (1) — POST /user/forgot-password reaches UserController

Express/Nest kernel identical to every other /user/* route (middleware - *, request handler - /* spans). Controller entrypoint at user.controller.ts:85-91UserController.sentForgotPasswordEmail (23.6 ms) parents the sentForgotPasswordEmail service span (19.1 ms).

4.2 Step (2) — ThrottleRecaptcha guard chain

ThrottleRecaptcha() is declared without options on the controller (user.controller.ts:84), so isCaptchaAlwaysRequired=true. The x-captcha-token: pass header short-circuits validation at recaptcha.service.ts:28 (isLocal && token === 'pass').

4.3 Step (3) — UserControllerUserService.resendPasswordResetEmail

Controller delegates to UserService.resendPasswordResetEmail at user.service.ts:846-870. The service has a silent-branch on email-miss (user.service.ts:867-869) — anti-enumeration intent, but see SF §6-4 for the cooldown side-channel that still leaks existence.

4.4 Step (4) — Prisma fat-hydration on findFirstUser({email})

A single findFirstUser({ email }) expands (via model-wide includes) into 6 prisma:engine:db_query spans: SELECTs against user, user_role, user_kyc, user_promo_code, user_permission, user_note. Only { id, email, username, googleEmail, steamId } is consumed at user.service.ts:851 — the five side-loads are wasted IO (§6-5).

4.5 Step (5) — Redis cooldown check + write

isResetPasswordCooldownActive at user.service.ts:970-982 emits get + set + expire spans against user:password-reset-cooldown:<email>: GET → if set throw USER_PASSWORD_RESET_COOLDOWN; else SET with 40 s TTL (PASSWORD_RESET_COOLDOWN_DURATION=40 at apps/api/src/user/const.ts:24). Key is per-email so enumeration of one account costs 40 s but rotating addresses is free.

4.6 Step (6) — JWT sign (no dedicated span)

jwtService.signAsync({ userId }, { secret: JWT_VERIFICATION_TOKEN_SECRET, expiresIn: '1200s' }) at user.service.ts:857-860. Synchronous HS256, CPU-only — not instrumented, no span emitted. The same secret signs email-verification tokens, so a leak rotates both flows (§6-1).

4.7 Step (7) — emailService.sendEmail (isLocal no-op)

email-sender.service.ts:63-65 returns undefined immediately when isLocal. No span, no outbound network, no record of the issued token in logs or DB. Local dev therefore cannot observe the token except by forging one with the shared secret. Handler returns 201 { result: "Password reset email sent" }.

4.8 Step (8) — PATCH /user/confirm-password-reset reaches UserController

UserController.resetUserPassword (100.8 ms) at user.controller.ts:94-100. No captcha decorator, no @UseGuards — anyone with a valid token can hit it.

4.9 Step (9) — UserControllerUserService.resetUserPassword

Controller delegates to UserService.resetUserPassword at user.service.ts:885-917 (inner resetUserPassword span = 99.0 ms).

4.10 Step (10) — @WaitMutex acquire + release on sha256(token)

Decorator on user.service.ts:885-888. Acquires Redis key user:reset-password:<sha256(token)> — emits set + get spans before the service body, then a tear-down unlink + publish after. Serializes replays of a single token but does not invalidate it — a second call with the same token after the first completes still succeeds while the JWT is within its 1200 s TTL (§6-2).

4.11 Step (11) — jwt.verify(token)

jwtService.verify(token, { secret: JWT_VERIFICATION_TOKEN_SECRET }) at user.service.ts:893. Throws USER_PASSWORD_RESET_INVALID_TOKEN on any failure (wrong signature, expired, malformed). No dedicated span (HS256, CPU-only).

4.12 Step (12) — findUniqueUser + UPDATE user.password (Prisma transaction)

Two sub-steps inside resetUserPassword:

  1. findUniqueUser(payload.userId)findUnique + 5 relation SELECTs, same fat-hydration as step (4).
  2. updateUniqueUser(payload.userId, { password }) — emits the BEGIN / UPDATE / SELECT-fan-out / COMMIT block seen in prisma:engine:db_query spans (10 queries total). The UPDATE targets only user.password + user.updated_at; the SELECTs are Prisma re-hydrating the model after the update.

4.13 Step (13) — validateAndHashPassword

validateAndHashPassword(newPassword) runs joi passwordComplexityDefaultOptions then bcrypt.hash(password, 10). Bcrypt is CPU-only and un-instrumented; the ~60 ms it dominates the request duration (104 ms total) is implicit. On joi rejection, throws USER_PASSWORD_VALIDATION_FAILED.

4.14 Step (14) — DEL login-attempts + lockout

cache.del(USER_LOGIN_ATTEMPTS_KEY_get(username)) + cache.del(USER_LOCKOUT_KEY_get(username)) at user.service.ts:914-915 — two unlink spans. Lets a previously-locked-out user sign in immediately with the new password. Handler returns 200 { result: "Password has been reset" }.

Not emitted on reset (intentionally called out):

  • No session.service.* span — existing access_token / refresh_token cookies are untouched (§6-3).
  • No BullMQ span — this flow emits nothing to the session queue.
  • No welcome / notification email — only the forgot-password path (step 7) touches EmailSenderService, and even that is a no-op locally.

5. Data model

Store Key / table R/W Fields touched Source
Postgres user R id, email, username, google_email, steam_id (fat-hydration loads all user_role/kyc/promo/permission/note rows too) libs/_prisma/src/schema/api.prisma:198-293
Postgres user W password, updated_at (single-row UPDATE in transaction) libs/_prisma/src/schema/api.prisma:198-293
Redis (cache) user:password-reset-cooldown:<email> R+W value '1', TTL 40 s apps/api/src/user/const.ts:24-26
Redis (cache) user:reset-password:<sha256(token)> R+W @WaitMutex lock key + pub/sub channel libs/shared WaitMutex decorator
Redis (cache) user:login-attempts:<username> DEL user.service.ts:914
Redis (cache) user:lockout:<username> DEL user.service.ts:915

No dedicated password_reset_token table exists. The token is stateless: a signed JWT with payload { userId, iat, exp }, verified only by signature + expiry.

6. Failure modes

  1. Forgeable reset token when the JWT secret leaks. JWT_VERIFICATION_TOKEN_SECRET signs both email-verification and password-reset tokens (user.service.ts:858, user.service.ts:893). A leak lets an attacker mint {userId: N} tokens for any account. The E2E spec demonstrates the capability at tests-e2e/tests/dropbet-password-reset.spec.ts:42-50. Mitigation would be a separate secret for each flow + a DB-side one-shot token table.
  2. Token is not one-use within its TTL. @WaitMutex on sha256(token) serializes concurrent replays but does not persist any "consumed" marker. A token remains valid for up to JWT_VERIFICATION_TOKEN_EXPIRATION_TIME=1200 s and can reset the password repeatedly — observed: two successful PATCHes with the same forged token in steps 2 & 4 of the E2E spec.
  3. Sessions not invalidated after reset. resetUserPassword (user.service.ts:889-917) never clears the auth-session:<userId>:* cache pattern or the cookies. An attacker who phished a password and the victim resets afterward retains their stolen access_token until its own expiry. Expected behavior for a password-reset flow is a session-wide logout.
  4. Email enumeration via cooldown side channel. resendPasswordResetEmail (user.service.ts:846-870) only writes user:password-reset-cooldown:<email> inside the if (user && user.email && …) branch. A repeat call within 40 s returns 400 only for addresses that actually exist — responses diverge on real vs unknown addresses despite the silent-return branch meant to hide it.
  5. Fat-hydration waste on findFirstUser. Both endpoints trigger 5–7 relation SELECTs per call (user_role, user_kyc, user_promo_code, user_permission, user_note, plus user_balance on the post-update rehydration). Prisma schema or service-layer projection should scope to the handful of fields actually consumed — current cost: ~6× the necessary Postgres round-trips per reset.

7. Unresolved

  • emailService.sendEmail produces no span in local because of the isLocal short-circuit at email-sender.service.ts:63-65. The non-local code path (SendGrid / SES / SMTP) is not exercised by this trace and is not documented here.
  • No span recorded the joi complexity-validation inside validateAndHashPassword; it is instrumented neither by Prisma nor by the default auto-instrumentations. Behavior confirmed only by HTTP-level error observation (USER_PASSWORD_VALIDATION_FAILED on rejection).