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.tsGenerated: 2026-04-16 · Services touched:ebit-api
1. User-visible contract¶
- Step 1 — request token.
POST http://localhost:4000/user/forgot-passwordwith{ userEmail }and headerx-captcha-token: pass(local bypass atebit-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-869silent-branch on miss — anti-enumeration). - Step 2 — submit new password.
PATCH http://localhost:4000/user/confirm-password-resetwith{ token, newPassword }. No captcha header. Returns 200 with{ result: "Password has been reset" }, or 400USER_PASSWORD_RESET_INVALID_TOKEN/USER_PASSWORD_VALIDATION_FAILED. - Rate limit. 40 s per-email cooldown (
user.service.ts:970-982, constPASSWORD_RESET_COOLDOWN_DURATION=40atapps/api/src/user/const.ts:24). A repeat inside the window returns 400USER_PASSWORD_RESET_COOLDOWN. - Side effects on success. Only the
user.passwordcolumn is written. Cache keysuser:login-attempts:<username>anduser:lockout:<username>are cleared (user.service.ts:913-916). Auth sessions are not invalidated — existingaccess_token/refresh_tokencookies remain valid. - Email. In local (
isLocal) the mailer short-circuits atemail-sender.service.ts:63-65— nothing is sent, nothing is logged. The test therefore forges the JWT client-side with the sharedJWT_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-91 — UserController.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) — UserController → UserService.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) — UserController → UserService.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:
findUniqueUser(payload.userId)—findUnique+ 5 relation SELECTs, same fat-hydration as step (4).updateUniqueUser(payload.userId, { password })— emits the BEGIN / UPDATE / SELECT-fan-out / COMMIT block seen inprisma:engine:db_queryspans (10 queries total). The UPDATE targets onlyuser.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 — existingaccess_token/refresh_tokencookies 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) touchesEmailSenderService, 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¶
- Forgeable reset token when the JWT secret leaks.
JWT_VERIFICATION_TOKEN_SECRETsigns 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 attests-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. - Token is not one-use within its TTL.
@WaitMutexonsha256(token)serializes concurrent replays but does not persist any "consumed" marker. A token remains valid for up toJWT_VERIFICATION_TOKEN_EXPIRATION_TIME=1200 sand can reset the password repeatedly — observed: two successful PATCHes with the same forged token in steps 2 & 4 of the E2E spec. - Sessions not invalidated after reset.
resetUserPassword(user.service.ts:889-917) never clears theauth-session:<userId>:*cache pattern or the cookies. An attacker who phished a password and the victim resets afterward retains their stolenaccess_tokenuntil its own expiry. Expected behavior for a password-reset flow is a session-wide logout. - Email enumeration via cooldown side channel.
resendPasswordResetEmail(user.service.ts:846-870) only writesuser:password-reset-cooldown:<email>inside theif (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. - 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, plususer_balanceon 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.sendEmailproduces no span in local because of the isLocal short-circuit atemail-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_FAILEDon rejection).