Skip to content

fix: add OTP cooldown/rate-limiting to requestPasswordReset endpoint#154

Merged
yash-pouranik merged 3 commits into
geturbackend:mainfrom
itsmessc:fix/password-reset-otp-cooldown
May 10, 2026
Merged

fix: add OTP cooldown/rate-limiting to requestPasswordReset endpoint#154
yash-pouranik merged 3 commits into
geturbackend:mainfrom
itsmessc:fix/password-reset-otp-cooldown

Conversation

@itsmessc
Copy link
Copy Markdown
Contributor

@itsmessc itsmessc commented May 6, 2026

Fixes #143

🛠️ Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)

🚀 What Changed

The requestPasswordReset endpoint had no cooldown between OTP requests, unlike signup and resendVerificationOtp which already enforced a 60-second window. This allowed an attacker who knows a valid email to flood that inbox with reset emails.

Changes made:

  • Added checkPublicOtpCooldown before sending the reset OTP — returns HTTP 429 with seconds remaining if within the 60s window
  • Added setPublicOtpCooldown after sending — sets a 60s Redis cooldown key (otp:cooldown:reset:<email>)
  • Added normalizedEmail (email.toLowerCase().trim()) to requestPasswordReset and resetPasswordUser so OTP keys are case-insensitive and consistent

🧪 Testing & Validation

Backend Verification:

  • I have run npm test in the backend/ directory and all tests passed.
  • I have verified the API endpoints using Postman/Thunder Client.
  • New unit tests have been added (if applicable).

New tests in userAuth.email.test.js:

  • Returns 429 with TTL when cooldown is active
  • Cooldown key is set with 60s TTL after a successful send
  • Normalized (lowercase) email is used in the Redis key
  • Happy path (no active cooldown) still returns 200

Manual verification (PowerShell):

  • 1st call → 200 { message: "If that email exists, a reset code has been sent." }
  • 2nd call immediately → 429 { error: "Please wait 57 seconds before requesting another code." }

✅ Checklist

  • My code follows the code style of this project.
  • I have performed a self-review of my code.
  • My changes generate no new warnings or errors.

Summary by CodeRabbit

  • Bug Fixes

    • Password reset, signup, and verification treat emails case-insensitively to avoid failed lookups.
    • OTP cooldowns are enforced consistently and return a standardized cooldown response including remaining seconds.
    • Cooldown keys are written even for unknown emails to prevent account enumeration; emails continue to be sent to the provided address.
  • Tests

    • Added tests for OTP cooldown behavior, expiry messaging, cooldown writes for unknown emails, and email normalization.

Review Change Stack

Copilot AI review requested due to automatic review settings May 6, 2026 15:35
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 137b48ad-7163-49d7-ba56-0909a9a8faf1

📥 Commits

Reviewing files that changed from the base of the PR and between 7983fa9 and 41e576b.

📒 Files selected for processing (2)
  • apps/public-api/src/__tests__/userAuth.email.test.js
  • apps/public-api/src/controllers/userAuth.controller.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/public-api/src/controllers/userAuth.controller.js

📝 Walkthrough

Walkthrough

Controller now normalizes emails (lowercase/trim) for reset/signup flows, enforces a 60s OTP cooldown via Redis (returns structured 429 when active), and tests were added to validate cooldown presence, TTL messaging, cooldown creation, and Redis key normalization.

Changes

Password-Reset Rate-Limiting and Email Normalization

Layer / File(s) Summary
Email Normalization
apps/public-api/src/controllers/userAuth.controller.js
requestPasswordReset and resetPasswordUser compute normalizedEmail (lowercased/trimmed) and use it for user lookup, Redis keys, and update queries.
Cooldown Response Shape
apps/public-api/src/controllers/userAuth.controller.js
signup and resendVerificationOtp now return { success: false, data: {}, message: ... } when OTP cooldown is hit instead of { error: ... }.
Rate-Limiting Implementation
apps/public-api/src/controllers/userAuth.controller.js
requestPasswordReset checks Redis for an active cooldown key and returns 429 with TTL message if present; after sending the reset OTP, it sets a 60s cooldown key using the normalized email.
Test Coverage
apps/public-api/src/__tests__/userAuth.email.test.js
Tests added/updated: ensure Redis get mocked to null for no-cooldown case, assert 429 with TTL when cooldown active (no email queued), assert cooldown redis.set uses EX 60, assert cooldown written for unknown emails, and assert Redis OTP key uses lowercased email.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as requestPasswordReset
    participant Redis
    participant Mail as Email Queue

    Client->>API: POST /requestPasswordReset (email)
    Note over API: Normalize email to lowercase
    
    API->>Redis: GET otp:reset:{normalizedEmail}:cooldown
    Redis-->>API: cooldown_key or null
    
    alt Cooldown Active
        API-->>Client: 429 with TTL
    else No Cooldown
        API->>Mail: Queue reset OTP email
        Mail-->>API: Queued
        
        API->>Redis: SET otp:reset:{normalizedEmail}:cooldown EX 60
        Redis-->>API: OK
        
        API-->>Client: 200 Generic Success Message
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • geturbackend/urBackend#141: Prior changes to OTP generation/storage in the same controller; related context for OTP handling.
  • geturbackend/urBackend#160: Modifies userAuth controller and Redis-related auth flows; related to password-reset Redis key behavior.

Suggested labels

level-1

Suggested reviewers

  • yash-pouranik

Poem

🐰 I nibble at keys, I lowercase with glee,
I hop past spam so inboxes breathe free.
Sixty heartbeats of calm, then a gentle beep,
Cooldowns keep night-watch while users sleep.
Hoppity secure, I guard each reset key.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding OTP cooldown/rate-limiting to the requestPasswordReset endpoint, which is the core objective of the PR.
Linked Issues check ✅ Passed All requirements from issue #143 are met: cooldown check added before OTP send, 60-second window enforced, consistent with existing endpoints, and account enumeration prevented via cooldown for unknown emails.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing OTP cooldown/rate-limiting for requestPasswordReset. Email normalization is a necessary supporting change for consistent key handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@yash-pouranik
Copy link
Copy Markdown
Collaborator

@coderabbitai
Please try again

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

@yash-pouranik Sure! I'll review the PR again right away.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/public-api/src/controllers/userAuth.controller.js (1)

1340-1357: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply reset cooldown uniformly to avoid account-enumeration side channel

Line 1341 returns early for unknown emails, while Lines 1345-1349 enforce cooldown only for existing users. Two rapid requests can reveal account existence (200 vs 429).

Suggested fix
-        const user = await Model.findOne({ email: normalizedEmail });
-        if (!user) {
-            return res.json({ message: "If that email exists, a reset code has been sent." });
-        }
-
         try {
             await checkPublicOtpCooldown(project._id, normalizedEmail, 'reset');
         } catch (cooldownErr) {
             return res.status(cooldownErr.statusCode || 429).json({ error: cooldownErr.message });
         }
+
+        const user = await Model.findOne({ email: normalizedEmail });
+        if (!user) {
+            // Keep response behavior indistinguishable for existing vs non-existing accounts
+            await setPublicOtpCooldown(project._id, normalizedEmail, 'reset');
+            return res.json({ message: "If that email exists, a reset code has been sent." });
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/public-api/src/controllers/userAuth.controller.js` around lines 1340 -
1357, Move the OTP cooldown check and the setting of the cooldown to occur
before returning for unknown emails so responses are indistinguishable;
specifically, call checkPublicOtpCooldown(project._id, normalizedEmail, 'reset')
immediately after computing normalizedEmail (before Model.findOne) and if it
passes, call setPublicOtpCooldown(project._id, normalizedEmail, 'reset') even
when Model.findOne(...) returns no user, then proceed to conditionally
create/store the OTP (redis.set) and enqueue email only for existing users but
always return the same generic message; keep using the existing functions
Model.findOne, checkPublicOtpCooldown, setPublicOtpCooldown, redis.set, and
authEmailQueue.add to implement this.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/public-api/src/controllers/userAuth.controller.js`:
- Around line 1348-1349: The current cooldown error response returns a raw {
error: ... } object which breaks the API envelope; replace that return with the
standard envelope using res.status(cooldownErr.statusCode || 429).json({
success: false, data: {}, message: cooldownErr.message }); and ensure any places
that construct cooldownErr use the AppError class instead of raw errors so we
never expose DB/internal errors directly.

---

Outside diff comments:
In `@apps/public-api/src/controllers/userAuth.controller.js`:
- Around line 1340-1357: Move the OTP cooldown check and the setting of the
cooldown to occur before returning for unknown emails so responses are
indistinguishable; specifically, call checkPublicOtpCooldown(project._id,
normalizedEmail, 'reset') immediately after computing normalizedEmail (before
Model.findOne) and if it passes, call setPublicOtpCooldown(project._id,
normalizedEmail, 'reset') even when Model.findOne(...) returns no user, then
proceed to conditionally create/store the OTP (redis.set) and enqueue email only
for existing users but always return the same generic message; keep using the
existing functions Model.findOne, checkPublicOtpCooldown, setPublicOtpCooldown,
redis.set, and authEmailQueue.add to implement this.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa7fdb30-a73d-4698-a467-7dd98aa8a753

📥 Commits

Reviewing files that changed from the base of the PR and between 0acd3fa and a74e877.

📒 Files selected for processing (2)
  • apps/public-api/src/__tests__/userAuth.email.test.js
  • apps/public-api/src/controllers/userAuth.controller.js

Comment thread apps/public-api/src/controllers/userAuth.controller.js Outdated
@yash-pouranik
Copy link
Copy Markdown
Collaborator

Last 1 change please
@itsmessc

@itsmessc
Copy link
Copy Markdown
Contributor Author

itsmessc commented May 9, 2026

sure i was waiting for the checks

@yash-pouranik
Copy link
Copy Markdown
Collaborator

@coderabbitai
I think now we are good to merge??

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the current state of the PR, open review comments, and checks
gh pr view 154 --repo geturbackend/urBackend --json state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,reviews

# Get open review comments (unresolved)
gh api repos/geturbackend/urBackend/pulls/154/reviews --jq '.[] | {id, state, user: .user.login, body: .body}'

Length of output: 14582


@yash-pouranik Almost — all CI checks are green ✅ and the PR is mergeable, but there is still one unresolved major security issue from my last review that should be addressed before merging.


🟠 Outstanding Issue: Account-Enumeration Side Channel (userAuth.controller.js, Lines 1340–1357)

The problem: The current order of operations is:

  1. Model.findOne(normalizedEmail) — if no user found → return 200 immediately (no cooldown applied)
  2. checkPublicOtpCooldown(...) — only reached if the user exists

This means an attacker can probe whether an email is registered:

  • Unknown email → always returns 200 instantly, never hits 429
  • Known email → second rapid request returns 429

Suggested fix — move the cooldown check before the DB lookup:

+ try {
+     await checkPublicOtpCooldown(project._id, normalizedEmail, 'reset');
+ } catch (cooldownErr) {
+     return res.status(cooldownErr.statusCode || 429).json({ success: false, data: {}, message: cooldownErr.message });
+ }

  const user = await Model.findOne({ email: normalizedEmail });
  if (!user) {
+     // Set cooldown even for unknown emails to prevent account enumeration
+     await setPublicOtpCooldown(project._id, normalizedEmail, 'reset');
      return res.json({ message: "If that email exists, a reset code has been sent." });
  }

- try {
-     await checkPublicOtpCooldown(project._id, normalizedEmail, 'reset');
- } catch (cooldownErr) {
-     return res.status(cooldownErr.statusCode || 429).json({ success: false, data: {}, message: cooldownErr.message });
- }

This ensures both known and unknown emails receive identical rate-limiting behaviour, making account existence indistinguishable to an attacker. Once this is addressed, the PR should be good to go! 🚀

@yash-pouranik
Copy link
Copy Markdown
Collaborator

last then will merge @itsmessc

@itsmessc
Copy link
Copy Markdown
Contributor Author

no worries i will always consider these comments

@itsmessc
Copy link
Copy Markdown
Contributor Author

now can you check i have added the fix

@yash-pouranik yash-pouranik merged commit 3a61fdb into geturbackend:main May 10, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add OTP cooldown/rate-limiting to requestPasswordReset endpoint

2 participants