Skip to content

Fix/otp rate limit security#89

Open
24211a05q8-hue wants to merge 8 commits into
kunalverma2512:mainfrom
24211a05q8-hue:fix/otp-rate-limit-security
Open

Fix/otp rate limit security#89
24211a05q8-hue wants to merge 8 commits into
kunalverma2512:mainfrom
24211a05q8-hue:fix/otp-rate-limit-security

Conversation

@24211a05q8-hue
Copy link
Copy Markdown

@24211a05q8-hue 24211a05q8-hue commented May 26, 2026

Implemented the requested review fixes and addressed the major security concerns:

  • Fixed OTP TTL vs lock duration mismatch
  • Added $set in OTP update methods
  • Sanitized login response
  • Added try/catch for GitHub OAuth state verification
  • Restored GitHub connect handling
  • Improved OTP resend/register handling
  • Added rate limiting logic
  • Updated OTP bcrypt hashing rounds

Please review the latest changes. Thank you!

Summary by CodeRabbit

Release Notes

  • New Features

    • Added OTP resend functionality for unverified users.
    • Implemented account lockout protection after 5 failed OTP attempts (15-minute lockout window).
  • Bug Fixes

    • Extended OTP validity period from 10 to 20 minutes.
    • Added rate limiting for OTP requests (10 per 15-minute window).
    • GitHub OAuth now requires account linking instead of automatic user creation.
    • Improved user verification enforcement across authentication flows.

Review Change Stack

@github-actions
Copy link
Copy Markdown

🚀 PR Received Successfully

Hello @24211a05q8-hue,

Thank you for taking the initiative to contribute to this project.

Please ensure that your PR follows all project guidelines properly before requesting review.

⚠️ Important Instructions

  • Maintain proper code quality and structure
  • Do not make unnecessary changes/files
  • Ensure responsiveness across devices
  • Follow existing project conventions strictly
  • Attach screenshots/videos for UI-related changes
  • Resolve merge conflicts before requesting review
  • Avoid AI-generated low quality PRs or copied implementations

📌 Mandatory for GSSoC'26 Participants

Joining the community group and announcement channel is compulsory for all contributors participating through GSSoC'26.

Failure to follow contribution guidelines may lead to PR rejection.

We appreciate your effort and wish you a great open-source journey ahead. ✨

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

This PR implements OTP request rate-limiting, attempt tracking, and temporary account lockouts (5 attempts, 15-minute locks). It adds hashed OTP storage, simplifies GitHub OAuth by removing user-creation logic and environment assertions, and refactors auth endpoints to return standardized user data shapes. Core changes span environment config, Mongoose schema, middleware, service logic, and route integration.

Changes

OTP Rate-Limiting and Attempt Tracking

Layer / File(s) Summary
Environment Configuration and Loading
server/config/env.js
Fixed .env loading from explicit path, expanded required variables to include GitHub OAuth, added debug logging, and changed default export to raw process.env.
OTP Data Model and Repository Methods
server/models/Otp.js, server/modules/auth/repository.js
Extended OTP TTL from 600 to 1200 seconds, added failedAttempts and lockUntil fields, enforced unique index on (email, purpose). Repository refactored to use $set updates and implemented upsert-based OTP creation with failure/lock state reset and new OTP safety helper methods.
Rate-Limiting Middleware and Validation Error Handling
server/middlewares/rateLimiter.js, server/modules/auth/validation.js
Created otpLimiter middleware allowing 10 OTP requests per 15-minute window. Updated Zod validation to derive errors from result.error.issues for both body and query validation.
Auth Service: OTP Handling, User Data, and GitHub OAuth
server/modules/auth/service.js
Introduced in-memory OTP rate-limiter and sanitizeUser helper. Upgraded signup and forgot-password OTP flows to use bcrypt hashing and enforce 5-attempt lockouts with 15-minute blocks. Simplified GitHub OAuth: removed user-creation and environment assertions, replaced with state-signed tokens (10m expiry) and database-lookup-only connect/login flows. All auth methods now return consistent sanitized user data (role, profile, handles, verification status).
Route Wiring and Middleware Integration
server/modules/auth/routes.js
Integrated otpLimiter into /verify-otp endpoint before validation. Reformatted route definitions while preserving GitHub OAuth middleware chains. All routes wired to updated service methods returning sanitized user responses.

Sequence Diagram

sequenceDiagram
  participant Client
  participant AuthService
  participant Repository
  participant Database
  participant Mailer
  Client->>AuthService: register(email, password)
  AuthService->>AuthService: checkSignupRateLimit(email)
  AuthService->>Repository: createOtp(email, 'signup')
  Repository->>Database: findOneAndUpdate (upsert, reset failures)
  AuthService->>Mailer: sendVerificationOtp(email)
  Client->>AuthService: verifyOtp(email, otp)
  AuthService->>Repository: findOtp(email, 'signup')
  alt OTP is locked
    AuthService-->>Client: Error - Account temporarily locked
  else OTP exists and not locked
    AuthService->>AuthService: compareHash(otp)
    alt Max attempts exceeded
      AuthService->>Repository: incrementFailures & lock
      AuthService-->>Client: Error - Too many attempts
    else Correct OTP
      AuthService->>Repository: clearOtp(email, 'signup')
      AuthService->>Repository: updateUserVerification
      AuthService-->>Client: accessToken + sanitized user
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Five attempts and you're locked away,
For fifteen minutes of the day,
Hashed codes keep secrets safe and sound,
GitHub flows more simple, tightly bound,
User shapes now sanitized and clean!

🚥 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 'Fix/otp rate limit security' directly and clearly summarizes the primary change—implementing OTP rate limiting and security fixes—which is confirmed by all modified files focusing on OTP handling, rate limiting middleware, and enhanced authentication security.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

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.

Copy link
Copy Markdown

@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: 6

🤖 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 `@server/models/Otp.js`:
- Around line 33-37: The createdAt field in the Otp mongoose schema uses
expires: 1200 (20 minutes) but OTP email templates state 10 minutes; either set
expires to 600 to match the templates or update the email templates to reflect
20 minutes—change the value in the createdAt config in server/models/Otp.js (the
createdAt field's expires option) and ensure the same canonical duration is used
in the OTP email generation code/templates so both code and copy share one
source of truth.

In `@server/modules/auth/repository.js`:
- Around line 118-137: Currently incrementOtpFailure() and lockOtp() perform two
separate writes which can race; replace them with a single atomic
findOneAndUpdate that increments failedAttempts and conditionally sets lockUntil
when the new failedAttempts meets/exceeds the threshold. Concretely, modify
incrementOtpFailure(email, purpose, { threshold = 5, lockMinutes = 15 } ) to
call Otp.findOneAndUpdate using either an aggregation-pipeline update (or a
conditional $setOnInsert/$cond in the pipeline) that does $inc: {
failedAttempts: 1 } and, if (failedAttempts + 1) >= threshold, sets lockUntil to
new Date(Date.now() + lockMinutes*60*1000); return the new document ({ new: true
}). Remove separate lockOtp usage from the service and ensure callers use the
new incrementOtpFailure signature.

In `@server/modules/auth/service.js`:
- Around line 227-257: The forgotPassword function currently throws a 404 when
AuthRepository.findUserByEmailWithoutPassword(email) returns null, which leaks
account existence; change it to always return the same success response and not
throw for missing users: keep the RATE_LIMIT_WINDOW check and otpRequestMap
behavior, but when user is null, avoid creating a real OTP or calling
sendPasswordResetOTP; instead perform equivalent-cost no-op work (e.g., generate
and bcrypt.hash a dummy OTP) so timing is similar, skip AuthRepository.createOtp
and sendPasswordResetOTP for non-existent users, and still return the same {
message: "Password reset OTP sent to your email" } result from forgotPassword.
- Around line 461-465: The controller expects AuthController.githubCallback to
return a redirect URL (used by AuthController.githubCallback ->
res.redirect(result.redirectUrl)), but the service currently returns { message,
token, user } which breaks the flow; update the service branches that build the
GitHub callback response to return an object containing redirectUrl (e.g., {
redirectUrl }) when the callback flow is used (instead of returning
token/user/message), or conditionally include redirectUrl alongside other data
only when appropriate; change the two affected return points (the branch that
currently returns message/token/user around sanitizeUser and appToken) so they
return the redirectUrl expected by AuthController.githubCallback.
- Around line 23-25: The in-memory otpRequestMap and RATE_LIMIT_WINDOW implement
a process-local limiter that is wiped on restart and can grow with
attacker-controlled keys; replace otpRequestMap with a shared rate-limit store
(e.g., Redis) and use atomic increment/expire operations (INCR + EXPIRE or a
single EVAL script) keyed by the normalized identifier (email or IP) to enforce
RATE_LIMIT_WINDOW across processes, set TTLs to automatically expire keys to
prevent unbounded growth, and update the code paths that currently read/write
otpRequestMap (look for otpRequestMap usage) to use the new shared-store client
with proper error handling and configuration.
- Around line 75-91: The register() flow resends OTP for unverified users
without checking account lock state, allowing a locked unverified account to
bypass the 15-minute lock because AuthRepository.createOtp() resets
failedAttempts/lockUntil; update the unverified branch to first check
existingUser.lockUntil (and/or failedAttempts) and if lockUntil is in the future
return an error/locked response instead of generating a new OTP, otherwise
proceed to generate/hash OTP and call AuthRepository.createOtp() and
sendVerificationOTP as before; reference existingUser.isVerified,
existingUser.lockUntil, register(), and AuthRepository.createOtp() to locate
changes.
🪄 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: e978fa3b-0828-44f4-8b1f-728ca07421ef

📥 Commits

Reviewing files that changed from the base of the PR and between 47f72c6 and d8049c5.

⛔ Files ignored due to path filters (1)
  • server/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • server/config/env.js
  • server/middlewares/rateLimiter.js
  • server/models/Otp.js
  • server/modules/auth/repository.js
  • server/modules/auth/routes.js
  • server/modules/auth/service.js
  • server/modules/auth/validation.js

Comment thread server/models/Otp.js
Comment on lines +33 to +37
createdAt: {
type: Date,
default: Date.now,
expires: 1200,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep OTP expiry aligned with the email copy.

expires: 1200 keeps OTPs around for 20 minutes, but both OTP email templates still tell users the code expires in 10 minutes. If 20 minutes is intentional to cover the 15-minute lockout window, the templates need to be updated from the same source of truth.

🤖 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 `@server/models/Otp.js` around lines 33 - 37, The createdAt field in the Otp
mongoose schema uses expires: 1200 (20 minutes) but OTP email templates state 10
minutes; either set expires to 600 to match the templates or update the email
templates to reflect 20 minutes—change the value in the createdAt config in
server/models/Otp.js (the createdAt field's expires option) and ensure the same
canonical duration is used in the OTP email generation code/templates so both
code and copy share one source of truth.

Comment on lines +118 to +137
static async incrementOtpFailure(email, purpose) {
return await Otp.findOneAndUpdate(
{ email, purpose },
{
$inc: { failedAttempts: 1 },
},
{ new: true }
);
}

static async lockOtp(email, purpose, lockMinutes = 15) {
return await Otp.findOneAndUpdate(
{ email, purpose },
{
$set: {
lockUntil: new Date(Date.now() + lockMinutes * 60 * 1000),
},
},
{ new: true }
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make OTP failure counting and lockout a single atomic update.

incrementOtpFailure() and lockOtp() are used as two separate writes from server/modules/auth/service.js. Concurrent bad submissions can increment past the threshold before the lock is written, which weakens the 5-attempt cap on a security-sensitive path.

🤖 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 `@server/modules/auth/repository.js` around lines 118 - 137, Currently
incrementOtpFailure() and lockOtp() perform two separate writes which can race;
replace them with a single atomic findOneAndUpdate that increments
failedAttempts and conditionally sets lockUntil when the new failedAttempts
meets/exceeds the threshold. Concretely, modify incrementOtpFailure(email,
purpose, { threshold = 5, lockMinutes = 15 } ) to call Otp.findOneAndUpdate
using either an aggregation-pipeline update (or a conditional $setOnInsert/$cond
in the pipeline) that does $inc: { failedAttempts: 1 } and, if (failedAttempts +
1) >= threshold, sets lockUntil to new Date(Date.now() + lockMinutes*60*1000);
return the new document ({ new: true }). Remove separate lockOtp usage from the
service and ensure callers use the new incrementOtpFailure signature.

Comment on lines +23 to +25
// simple in-memory rate limiter (DEV LEVEL)
const otpRequestMap = new Map();
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 min
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The OTP request limiter is process-local and resettable.

This Map only throttles a single Node process and is wiped on restart, so scaling out or recycling the app drops the protection entirely. It also grows with attacker-controlled email keys. For public auth endpoints, this needs a shared store with expiry instead of in-memory state.

🤖 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 `@server/modules/auth/service.js` around lines 23 - 25, The in-memory
otpRequestMap and RATE_LIMIT_WINDOW implement a process-local limiter that is
wiped on restart and can grow with attacker-controlled keys; replace
otpRequestMap with a shared rate-limit store (e.g., Redis) and use atomic
increment/expire operations (INCR + EXPIRE or a single EVAL script) keyed by the
normalized identifier (email or IP) to enforce RATE_LIMIT_WINDOW across
processes, set TTLs to automatically expire keys to prevent unbounded growth,
and update the code paths that currently read/write otpRequestMap (look for
otpRequestMap usage) to use the new shared-store client with proper error
handling and configuration.

Comment on lines +75 to +91
// UNVERIFIED USER EXISTS → RESEND OTP
if (existingUser && !existingUser.isVerified) {
const plainOtp = generateOTP();
const hashedOtp = await bcrypt.hash(plainOtp, 6);

await AuthRepository.createOtp({
email,
otp: hashedOtp,
purpose: "signup",
});

await sendVerificationOTP(email, plainOtp);

return {
message: "Verification OTP resent successfully",
user: sanitizeUser(existingUser),
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

register() currently bypasses the OTP lockout for unverified users.

This branch resends immediately and AuthRepository.createOtp() resets failedAttempts and lockUntil. A locked, unverified account can therefore hit /register again and get a fresh OTP without waiting out the 15-minute lock.

🔒 Suggested guard
     if (existingUser && !existingUser.isVerified) {
+      const existingOtp = await AuthRepository.findOtp(email, "signup");
+
+      if (existingOtp?.lockUntil && existingOtp.lockUntil > new Date()) {
+        throw new ApiError(
+          429,
+          `Too many failed attempts. Try again after ${OTP_LOCK_MINUTES} minutes`
+        );
+      }
+
       const plainOtp = generateOTP();
       const hashedOtp = await bcrypt.hash(plainOtp, 6);
🤖 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 `@server/modules/auth/service.js` around lines 75 - 91, The register() flow
resends OTP for unverified users without checking account lock state, allowing a
locked unverified account to bypass the 15-minute lock because
AuthRepository.createOtp() resets failedAttempts/lockUntil; update the
unverified branch to first check existingUser.lockUntil (and/or failedAttempts)
and if lockUntil is in the future return an error/locked response instead of
generating a new OTP, otherwise proceed to generate/hash OTP and call
AuthRepository.createOtp() and sendVerificationOTP as before; reference
existingUser.isVerified, existingUser.lockUntil, register(), and
AuthRepository.createOtp() to locate changes.

Comment on lines 227 to 257
static async forgotPassword({ email }) {
// Find user
const user = await AuthRepository.findUserByEmailWithoutPassword(email);
const key = `${email}-forgot-password`;
const last = otpRequestMap.get(key);

if (last && Date.now() - last < RATE_LIMIT_WINDOW) {
throw new ApiError(429, "Please wait before requesting OTP");
}

otpRequestMap.set(key, Date.now());

const user =
await AuthRepository.findUserByEmailWithoutPassword(email);

if (!user) {
throw new ApiError(404, "User not found");
}

// Generate OTP
const plainOtp = generateOTP();
const hashedOtp = await bcrypt.hash(plainOtp, 4);
const hashedOtp = await bcrypt.hash(plainOtp, 6);

// Store hashed OTP
await AuthRepository.createOtp({
email,
otp: hashedOtp,
purpose: "forgot-password"
purpose: "forgot-password",
});

// Send password reset email
await sendPasswordResetOTP(email, plainOtp);

return {
message: "Password reset OTP sent to your email"
message: "Password reset OTP sent to your email",
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't disclose whether an email is registered from forgotPassword().

This still returns 404 "User not found" on a public password-reset endpoint, which lets attackers enumerate valid accounts. The safer pattern is to return the same success response whether the email exists or not.

🤖 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 `@server/modules/auth/service.js` around lines 227 - 257, The forgotPassword
function currently throws a 404 when
AuthRepository.findUserByEmailWithoutPassword(email) returns null, which leaks
account existence; change it to always return the same success response and not
throw for missing users: keep the RATE_LIMIT_WINDOW check and otpRequestMap
behavior, but when user is null, avoid creating a real OTP or calling
sendPasswordResetOTP; instead perform equivalent-cost no-op work (e.g., generate
and bcrypt.hash a dummy OTP) so timing is similar, skip AuthRepository.createOtp
and sendPasswordResetOTP for non-existent users, and still return the same {
message: "Password reset OTP sent to your email" } result from forgotPassword.

Comment on lines +461 to +465
return {
message: "GitHub account connected successfully",
token: appToken,
user: sanitizeUser(user),
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

The GitHub callback response no longer matches the controller contract.

AuthController.githubCallback still does res.redirect(result.redirectUrl) in server/modules/auth/controller.js:87-111, but both branches here now return { message, token, user }. That breaks the callback flow instead of completing auth.

Also applies to: 486-490

🤖 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 `@server/modules/auth/service.js` around lines 461 - 465, The controller
expects AuthController.githubCallback to return a redirect URL (used by
AuthController.githubCallback -> res.redirect(result.redirectUrl)), but the
service currently returns { message, token, user } which breaks the flow; update
the service branches that build the GitHub callback response to return an object
containing redirectUrl (e.g., { redirectUrl }) when the callback flow is used
(instead of returning token/user/message), or conditionally include redirectUrl
alongside other data only when appropriate; change the two affected return
points (the branch that currently returns message/token/user around sanitizeUser
and appToken) so they return the redirectUrl expected by
AuthController.githubCallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant