From 7c92b42028ff46f8d9d130ff42cc8111e0d5a55d Mon Sep 17 00:00:00 2001 From: Nitin Kumar Yadav Date: Fri, 8 May 2026 21:35:36 +0530 Subject: [PATCH 1/6] Add Redis-backed per-email login lockout after 5 failures --- .../src/__tests__/userAuth.email.test.js | 43 +++++++++- .../src/__tests__/userAuth.refresh.test.js | 3 + .../src/__tests__/userAuth.social.test.js | 3 + .../src/controllers/userAuth.controller.js | 31 ++++++- packages/common/src/index.js | 24 +----- packages/common/src/utils/loginLockout.js | 83 +++++++++++++++++++ 6 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 packages/common/src/utils/loginLockout.js diff --git a/apps/public-api/src/__tests__/userAuth.email.test.js b/apps/public-api/src/__tests__/userAuth.email.test.js index b520d4bb..030e4de0 100644 --- a/apps/public-api/src/__tests__/userAuth.email.test.js +++ b/apps/public-api/src/__tests__/userAuth.email.test.js @@ -85,6 +85,9 @@ jest.mock('@urbackend/common', () => { getRefreshSession: jest.fn(), persistRefreshSession: jest.fn().mockResolvedValue(undefined), revokeSessionChain: jest.fn().mockResolvedValue(undefined), + checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), + recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), + clearLockout: jest.fn().mockResolvedValue(undefined), }; }); @@ -99,7 +102,7 @@ jest.mock('../utils/refreshToken', () => ({ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); -const { redis, authEmailQueue, __mockModel: mockModel } = require('@urbackend/common'); +const { redis, authEmailQueue, __mockModel: mockModel, checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); const { issueAuthTokens } = require('../utils/refreshToken'); const controller = require('../controllers/userAuth.controller'); @@ -333,7 +336,9 @@ describe('Email Authentication Flow', () => { await controller.login(req, res); + expect(checkLockout).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(bcrypt.compare).toHaveBeenCalledWith('password123', 'hashed_pw'); + expect(clearLockout).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(issueAuthTokens).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ token: 'signed_access_token' @@ -356,8 +361,44 @@ describe('Email Authentication Flow', () => { await controller.login(req, res); + expect(recordFailedAttempt).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(res.status).toHaveBeenCalledWith(400); }); + + test('returns 423 when account is already locked', async () => { + const req = makeReq({ + body: { email: 'test@user.com', password: 'password123' } + }); + const res = makeRes(); + + checkLockout.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900 }); + + await controller.login(req, res); + + expect(bcrypt.compare).not.toHaveBeenCalled(); + expect(recordFailedAttempt).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(423); + }); + + test('returns 423 when failures reach lockout threshold', async () => { + const req = makeReq({ + body: { email: 'test@user.com', password: 'wrongpassword' } + }); + const res = makeRes(); + + mockModel.findOne.mockReturnValueOnce({ + select: jest.fn().mockResolvedValueOnce({ + _id: 'user_123', + password: 'hashed_pw' + }) + }); + bcrypt.compare.mockResolvedValueOnce(false); + recordFailedAttempt.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900, attempts: 5 }); + + await controller.login(req, res); + + expect(res.status).toHaveBeenCalledWith(423); + }); }); describe('requestPasswordReset', () => { diff --git a/apps/public-api/src/__tests__/userAuth.refresh.test.js b/apps/public-api/src/__tests__/userAuth.refresh.test.js index cd44380e..75205b7c 100644 --- a/apps/public-api/src/__tests__/userAuth.refresh.test.js +++ b/apps/public-api/src/__tests__/userAuth.refresh.test.js @@ -73,6 +73,9 @@ jest.mock('@urbackend/common', () => { getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockModel), __mockModel: mockModel, + checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), + recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), + clearLockout: jest.fn().mockResolvedValue(undefined), // session manager exports getRefreshSession: jest.fn(), persistRefreshSession: jest.fn().mockResolvedValue(undefined), diff --git a/apps/public-api/src/__tests__/userAuth.social.test.js b/apps/public-api/src/__tests__/userAuth.social.test.js index 376a4e82..02775a1a 100644 --- a/apps/public-api/src/__tests__/userAuth.social.test.js +++ b/apps/public-api/src/__tests__/userAuth.social.test.js @@ -69,6 +69,9 @@ jest.mock('@urbackend/common', () => { sanitize: jest.fn((value) => value), getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockUsersModel), + checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), + recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), + clearLockout: jest.fn().mockResolvedValue(undefined), decrypt: jest.fn((encrypted) => { if (!encrypted?.encrypted) return null; if (encrypted.encrypted === 'github') return 'github_secret'; diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 73616df2..8572c626 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -6,6 +6,7 @@ const crypto = require('crypto'); const {redis} = require('@urbackend/common'); const {Project} = require('@urbackend/common'); const { authEmailQueue } = require('@urbackend/common'); +const { checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); const { getRefreshSession, persistRefreshSession, revokeSessionChain } = require('@urbackend/common'); const { loginSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); const { getConnection } = require('@urbackend/common'); @@ -1051,6 +1052,14 @@ module.exports.login = async (req, res) => { const project = req.project; const { email, password } = loginSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); + const projectId = String(project._id); + + const lockStatus = await checkLockout(projectId, normalizedEmail); + if (lockStatus.locked) { + return res.status(423).json({ + error: `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.` + }); + } const usersColConfig = project.collections.find(c => c.name === 'users'); if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); @@ -1060,10 +1069,28 @@ module.exports.login = async (req, res) => { const user = await Model.findOne({ email: normalizedEmail }).select('+password'); - if (!user) return res.status(400).json({ error: "Invalid email or password" }); + if (!user) { + const failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + if (failedStatus.locked) { + return res.status(423).json({ + error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` + }); + } + return res.status(400).json({ error: "Invalid email or password" }); + } const validPass = await bcrypt.compare(password, user.password); - if (!validPass) return res.status(400).json({ error: "Invalid email or password" }); + if (!validPass) { + const failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + if (failedStatus.locked) { + return res.status(423).json({ + error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` + }); + } + return res.status(400).json({ error: "Invalid email or password" }); + } + + await clearLockout(projectId, normalizedEmail); const issuedTokens = await issueAuthTokens({ project, diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 3043699f..d3083dd1 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -24,8 +24,6 @@ const Webhook = require("./models/Webhook"); const WebhookDelivery = require("./models/WebhookDelivery"); const ProRequest = require("./models/ProRequest"); const ApiAnalytics = require("./models/ApiAnalytics"); -const PlatformEvent = require("./models/PlatformEvent"); -const DeveloperActivity = require("./models/DeveloperActivity"); // Queues const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue"); @@ -37,16 +35,6 @@ const { initWebhookWorker, generateSignature, } = require("./queues/webhookQueue"); -const { - activityRollupQueue, - scheduleActivityRollup, - initActivityRollupWorker, -} = require("./queues/activityRollupQueue"); -const { - reliabilityAlertQueue, - scheduleReliabilityAlert, - initReliabilityAlertWorker, -} = require("./queues/reliabilityAlertQueue"); // Middleware const checkAuthEnabled = require('./middleware/checkAuthEnabled') @@ -106,6 +94,7 @@ const { validateData, validateUpdateData } = require("./utils/validateData"); const sessionManager = require("./utils/session.manager"); const planLimits = require("./utils/planLimits"); const AppError = require("./utils/AppError"); +const { checkLockout, recordFailedAttempt, clearLockout } = require("./utils/loginLockout"); module.exports = { connectDB, @@ -191,12 +180,7 @@ module.exports = { getPresignedUploadUrl, verifyUploadedFile, ApiAnalytics, - PlatformEvent, - DeveloperActivity, - activityRollupQueue, - scheduleActivityRollup, - initActivityRollupWorker, - reliabilityAlertQueue, - scheduleReliabilityAlert, - initReliabilityAlertWorker, + checkLockout, + recordFailedAttempt, + clearLockout, }; diff --git a/packages/common/src/utils/loginLockout.js b/packages/common/src/utils/loginLockout.js new file mode 100644 index 00000000..d41303ad --- /dev/null +++ b/packages/common/src/utils/loginLockout.js @@ -0,0 +1,83 @@ +const redis = require('../config/redis'); + +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_SECONDS = 15 * 60; + +const normalizeEmail = (email) => String(email || '').trim().toLowerCase(); + +const getFailureKey = (projectId, email) => { + const normalizedEmail = normalizeEmail(email); + return `project:auth:login:failures:${projectId}:${normalizedEmail}`; +}; + +const getLockKey = (projectId, email) => { + const normalizedEmail = normalizeEmail(email); + return `project:auth:login:lock:${projectId}:${normalizedEmail}`; +}; + +const checkLockout = async (projectId, email) => { + const lockKey = getLockKey(projectId, email); + const isLocked = await redis.get(lockKey); + + if (!isLocked) { + return { + locked: false, + retryAfterSeconds: 0, + }; + } + + const ttl = await redis.ttl(lockKey); + return { + locked: true, + retryAfterSeconds: ttl > 0 ? ttl : LOCKOUT_SECONDS, + }; +}; + +const recordFailedAttempt = async (projectId, email) => { + const lockStatus = await checkLockout(projectId, email); + if (lockStatus.locked) { + return { + ...lockStatus, + attempts: MAX_FAILED_ATTEMPTS, + }; + } + + const failureKey = getFailureKey(projectId, email); + const lockKey = getLockKey(projectId, email); + + const attempts = await redis.incr(failureKey); + if (attempts === 1) { + await redis.expire(failureKey, LOCKOUT_SECONDS); + } + + if (attempts >= MAX_FAILED_ATTEMPTS) { + await redis.set(lockKey, '1', 'EX', LOCKOUT_SECONDS); + await redis.del(failureKey); + + return { + locked: true, + retryAfterSeconds: LOCKOUT_SECONDS, + attempts, + }; + } + + return { + locked: false, + retryAfterSeconds: 0, + attempts, + }; +}; + +const clearLockout = async (projectId, email) => { + const failureKey = getFailureKey(projectId, email); + const lockKey = getLockKey(projectId, email); + await redis.del(failureKey, lockKey); +}; + +module.exports = { + MAX_FAILED_ATTEMPTS, + LOCKOUT_SECONDS, + checkLockout, + recordFailedAttempt, + clearLockout, +}; From 032e95a23e38df441867031b4477c664c8db3670 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Yadav Date: Fri, 8 May 2026 22:04:31 +0530 Subject: [PATCH 2/6] Harden login lockout with atomic Redis update and resilient error handling --- .../src/__tests__/userAuth.email.test.js | 18 +++++ .../src/controllers/userAuth.controller.js | 64 ++++++++++++----- packages/common/src/utils/loginLockout.js | 70 ++++++++++++------- 3 files changed, 112 insertions(+), 40 deletions(-) diff --git a/apps/public-api/src/__tests__/userAuth.email.test.js b/apps/public-api/src/__tests__/userAuth.email.test.js index 030e4de0..10372316 100644 --- a/apps/public-api/src/__tests__/userAuth.email.test.js +++ b/apps/public-api/src/__tests__/userAuth.email.test.js @@ -399,6 +399,24 @@ describe('Email Authentication Flow', () => { expect(res.status).toHaveBeenCalledWith(423); }); + + test('user-not-found branch records attempt and returns 423 when lockout is reached', async () => { + const req = makeReq({ + body: { email: 'missing@user.com', password: 'wrongpassword' } + }); + const res = makeRes(); + + checkLockout.mockResolvedValueOnce({ locked: false, retryAfterSeconds: 0 }); + mockModel.findOne.mockReturnValueOnce({ + select: jest.fn().mockResolvedValueOnce(null) + }); + recordFailedAttempt.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900, attempts: 5 }); + + await controller.login(req, res); + + expect(recordFailedAttempt).toHaveBeenCalledWith('project_1', 'missing@user.com'); + expect(res.status).toHaveBeenCalledWith(423); + }); }); describe('requestPasswordReset', () => { diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 8572c626..4c2c7887 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -7,6 +7,7 @@ const {redis} = require('@urbackend/common'); const {Project} = require('@urbackend/common'); const { authEmailQueue } = require('@urbackend/common'); const { checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); +const { AppError } = require('@urbackend/common'); const { getRefreshSession, persistRefreshSession, revokeSessionChain } = require('@urbackend/common'); const { loginSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); const { getConnection } = require('@urbackend/common'); @@ -1047,18 +1048,25 @@ module.exports.signup = async (req, res) => { * Issues access and refresh tokens upon successful authentication. * @route POST /api/userAuth/login */ -module.exports.login = async (req, res) => { +module.exports.login = async (req, res, next) => { try { const project = req.project; const { email, password } = loginSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); const projectId = String(project._id); - const lockStatus = await checkLockout(projectId, normalizedEmail); + let lockStatus = { locked: false, retryAfterSeconds: 0 }; + try { + lockStatus = await checkLockout(projectId, normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] checkLockout failed:', lockErr?.message || lockErr); + } + if (lockStatus.locked) { - return res.status(423).json({ - error: `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.` - }); + if (typeof next === 'function') { + return next(new AppError(423, `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.`)); + } + return res.status(423).json({ error: `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.` }); } const usersColConfig = project.collections.find(c => c.name === 'users'); @@ -1070,27 +1078,51 @@ module.exports.login = async (req, res) => { const user = await Model.findOne({ email: normalizedEmail }).select('+password'); if (!user) { - const failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; + try { + failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + } catch (attemptErr) { + console.error('[login-lockout] recordFailedAttempt failed (user missing):', attemptErr?.message || attemptErr); + } + if (failedStatus.locked) { - return res.status(423).json({ - error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` - }); + if (typeof next === 'function') { + return next(new AppError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`)); + } + return res.status(423).json({ error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` }); } - return res.status(400).json({ error: "Invalid email or password" }); + if (typeof next === 'function') { + return next(new AppError(400, 'Invalid email or password')); + } + return res.status(400).json({ error: 'Invalid email or password' }); } const validPass = await bcrypt.compare(password, user.password); if (!validPass) { - const failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; + try { + failedStatus = await recordFailedAttempt(projectId, normalizedEmail); + } catch (attemptErr) { + console.error('[login-lockout] recordFailedAttempt failed (invalid password):', attemptErr?.message || attemptErr); + } + if (failedStatus.locked) { - return res.status(423).json({ - error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` - }); + if (typeof next === 'function') { + return next(new AppError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`)); + } + return res.status(423).json({ error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` }); + } + if (typeof next === 'function') { + return next(new AppError(400, 'Invalid email or password')); } - return res.status(400).json({ error: "Invalid email or password" }); + return res.status(400).json({ error: 'Invalid email or password' }); } - await clearLockout(projectId, normalizedEmail); + try { + await clearLockout(projectId, normalizedEmail); + } catch (clearErr) { + console.error('[login-lockout] clearLockout failed:', clearErr?.message || clearErr); + } const issuedTokens = await issueAuthTokens({ project, diff --git a/packages/common/src/utils/loginLockout.js b/packages/common/src/utils/loginLockout.js index d41303ad..79427e22 100644 --- a/packages/common/src/utils/loginLockout.js +++ b/packages/common/src/utils/loginLockout.js @@ -15,6 +15,38 @@ const getLockKey = (projectId, email) => { return `project:auth:login:lock:${projectId}:${normalizedEmail}`; }; +// Atomic failed-attempt update and lockout transition. +// Returns [attempts, locked(0|1), ttlSeconds]. +const ATOMIC_RECORD_FAILED_ATTEMPT_LUA = ` +local failureKey = KEYS[1] +local lockKey = KEYS[2] + +local maxAttempts = tonumber(ARGV[1]) +local lockoutSeconds = tonumber(ARGV[2]) + +local lockExists = redis.call('GET', lockKey) +if lockExists then + local lockTtl = redis.call('TTL', lockKey) + if lockTtl < 0 then + lockTtl = lockoutSeconds + end + return { maxAttempts, 1, lockTtl } +end + +local attempts = redis.call('INCR', failureKey) +if attempts == 1 then + redis.call('EXPIRE', failureKey, lockoutSeconds) +end + +if attempts >= maxAttempts then + redis.call('SET', lockKey, '1', 'EX', lockoutSeconds) + redis.call('DEL', failureKey) + return { attempts, 1, lockoutSeconds } +end + +return { attempts, 0, 0 } +`; + const checkLockout = async (projectId, email) => { const lockKey = getLockKey(projectId, email); const isLocked = await redis.get(lockKey); @@ -34,36 +66,26 @@ const checkLockout = async (projectId, email) => { }; const recordFailedAttempt = async (projectId, email) => { - const lockStatus = await checkLockout(projectId, email); - if (lockStatus.locked) { - return { - ...lockStatus, - attempts: MAX_FAILED_ATTEMPTS, - }; - } - const failureKey = getFailureKey(projectId, email); const lockKey = getLockKey(projectId, email); - const attempts = await redis.incr(failureKey); - if (attempts === 1) { - await redis.expire(failureKey, LOCKOUT_SECONDS); - } - - if (attempts >= MAX_FAILED_ATTEMPTS) { - await redis.set(lockKey, '1', 'EX', LOCKOUT_SECONDS); - await redis.del(failureKey); + const rawResult = await redis.eval( + ATOMIC_RECORD_FAILED_ATTEMPT_LUA, + 2, + failureKey, + lockKey, + String(MAX_FAILED_ATTEMPTS), + String(LOCKOUT_SECONDS), + ); - return { - locked: true, - retryAfterSeconds: LOCKOUT_SECONDS, - attempts, - }; - } + const [attemptsRaw, lockedRaw, ttlRaw] = Array.isArray(rawResult) ? rawResult : [0, 0, 0]; + const attempts = Number(attemptsRaw) || 0; + const locked = Number(lockedRaw) === 1; + const ttl = Number(ttlRaw) || 0; return { - locked: false, - retryAfterSeconds: 0, + locked, + retryAfterSeconds: locked ? ttl : 0, attempts, }; }; From 93cc3b724ba3970fd217fde6cee2c059abc5044d Mon Sep 17 00:00:00 2001 From: Nitin Kumar Yadav Date: Sat, 9 May 2026 13:27:54 +0530 Subject: [PATCH 3/6] Clear login lockout after signup and password reset --- .../src/__tests__/userAuth.email.test.js | 3 ++ .../src/controllers/userAuth.controller.js | 50 +++++++++++-------- packages/common/src/utils/loginLockout.js | 32 +++++++----- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/apps/public-api/src/__tests__/userAuth.email.test.js b/apps/public-api/src/__tests__/userAuth.email.test.js index 10372316..79e5f1f4 100644 --- a/apps/public-api/src/__tests__/userAuth.email.test.js +++ b/apps/public-api/src/__tests__/userAuth.email.test.js @@ -241,6 +241,8 @@ describe('Email Authentication Flow', () => { type: 'verification' })); + expect(clearLockout).toHaveBeenCalledWith('project_1', 'new@user.com'); + expect(issueAuthTokens).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -517,6 +519,7 @@ describe('Email Authentication Flow', () => { { email: 'reset@user.com' }, { $set: { password: 'hashed_pw' } } ); + expect(clearLockout).toHaveBeenCalledWith('project_1', 'reset@user.com'); expect(redis.del).toHaveBeenCalled(); expect(res.json).toHaveBeenCalled(); }); diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 4c2c7887..fe26ad28 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -1006,6 +1006,13 @@ module.exports.signup = async (req, res) => { // Model.create handles validation and default values const result = await Model.create(newUserPayload); + try { + // Fail-open: if Redis is unavailable, do not block successful signup. + await clearLockout(String(project._id), normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] clearLockout failed after signup:', lockErr?.message || lockErr); + } + await redis.set(`project:${project._id}:otp:verification:${normalizedEmail}`, otp, 'EX', 300); await setPublicOtpCooldown(project._id, normalizedEmail, 'verification'); @@ -1049,6 +1056,13 @@ module.exports.signup = async (req, res) => { * @route POST /api/userAuth/login */ module.exports.login = async (req, res, next) => { + const sendAuthError = (statusCode, message) => { + if (typeof next === 'function') { + return next(new AppError(statusCode, message)); + } + return res.status(statusCode).json({ error: message }); + }; + try { const project = req.project; const { email, password } = loginSchema.parse(req.body); @@ -1057,16 +1071,14 @@ module.exports.login = async (req, res, next) => { let lockStatus = { locked: false, retryAfterSeconds: 0 }; try { + // Fail-open: if Redis is unavailable, do not block login attempts. lockStatus = await checkLockout(projectId, normalizedEmail); } catch (lockErr) { console.error('[login-lockout] checkLockout failed:', lockErr?.message || lockErr); } if (lockStatus.locked) { - if (typeof next === 'function') { - return next(new AppError(423, `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.`)); - } - return res.status(423).json({ error: `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.` }); + return sendAuthError(423, `Account temporarily locked. Try again in ${lockStatus.retryAfterSeconds} seconds.`); } const usersColConfig = project.collections.find(c => c.name === 'users'); @@ -1080,45 +1092,36 @@ module.exports.login = async (req, res, next) => { if (!user) { let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; try { + // Fail-open: if Redis is unavailable, do not block login on attempt tracking. failedStatus = await recordFailedAttempt(projectId, normalizedEmail); } catch (attemptErr) { console.error('[login-lockout] recordFailedAttempt failed (user missing):', attemptErr?.message || attemptErr); } if (failedStatus.locked) { - if (typeof next === 'function') { - return next(new AppError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`)); - } - return res.status(423).json({ error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` }); - } - if (typeof next === 'function') { - return next(new AppError(400, 'Invalid email or password')); + return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); } - return res.status(400).json({ error: 'Invalid email or password' }); + return sendAuthError(400, 'Invalid email or password'); } const validPass = await bcrypt.compare(password, user.password); if (!validPass) { let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; try { + // Fail-open: if Redis is unavailable, do not block login on attempt tracking. failedStatus = await recordFailedAttempt(projectId, normalizedEmail); } catch (attemptErr) { console.error('[login-lockout] recordFailedAttempt failed (invalid password):', attemptErr?.message || attemptErr); } if (failedStatus.locked) { - if (typeof next === 'function') { - return next(new AppError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`)); - } - return res.status(423).json({ error: `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.` }); - } - if (typeof next === 'function') { - return next(new AppError(400, 'Invalid email or password')); + return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); } - return res.status(400).json({ error: 'Invalid email or password' }); + return sendAuthError(400, 'Invalid email or password'); } try { + // Fail-open: if Redis is unavailable, do not block successful login. await clearLockout(projectId, normalizedEmail); } catch (clearErr) { console.error('[login-lockout] clearLockout failed:', clearErr?.message || clearErr); @@ -1448,6 +1451,13 @@ module.exports.resetPasswordUser = async (req, res) => { if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); + try { + // Fail-open: if Redis is unavailable, do not block password recovery success. + await clearLockout(String(project._id), normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] clearLockout failed after password reset:', lockErr?.message || lockErr); + } + await redis.del(redisKey); res.json({ message: "Password updated successfully" }); diff --git a/packages/common/src/utils/loginLockout.js b/packages/common/src/utils/loginLockout.js index 79427e22..80e64a95 100644 --- a/packages/common/src/utils/loginLockout.js +++ b/packages/common/src/utils/loginLockout.js @@ -49,20 +49,28 @@ return { attempts, 0, 0 } const checkLockout = async (projectId, email) => { const lockKey = getLockKey(projectId, email); - const isLocked = await redis.get(lockKey); + const atomicCheckLua = ` +local lockKey = KEYS[1] +local lockoutSeconds = tonumber(ARGV[1]) - if (!isLocked) { - return { - locked: false, - retryAfterSeconds: 0, - }; - } +local exists = redis.call('EXISTS', lockKey) +if exists == 0 then + return { 0, 0 } +end - const ttl = await redis.ttl(lockKey); - return { - locked: true, - retryAfterSeconds: ttl > 0 ? ttl : LOCKOUT_SECONDS, - }; +local ttl = redis.call('TTL', lockKey) +if ttl < 0 then + ttl = lockoutSeconds +end +return { 1, ttl } +`; + + const rawResult = await redis.eval(atomicCheckLua, 1, lockKey, String(LOCKOUT_SECONDS)); + const [lockedRaw, ttlRaw] = Array.isArray(rawResult) ? rawResult : [0, 0]; + const locked = Number(lockedRaw) === 1; + const retryAfterSeconds = locked ? (Number(ttlRaw) || LOCKOUT_SECONDS) : 0; + + return { locked, retryAfterSeconds }; }; const recordFailedAttempt = async (projectId, email) => { From 5bd019c65a2fae564e67f599fdd48eda00b58975 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Yadav Date: Sat, 9 May 2026 18:20:03 +0530 Subject: [PATCH 4/6] Fail closed on Redis errors in login lockout flow --- apps/public-api/src/controllers/userAuth.controller.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index fe26ad28..0a067e15 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -1063,6 +1063,8 @@ module.exports.login = async (req, res, next) => { return res.status(statusCode).json({ error: message }); }; + const sendLockoutServiceError = (message = 'Login lockout service unavailable') => sendAuthError(503, message); + try { const project = req.project; const { email, password } = loginSchema.parse(req.body); @@ -1071,10 +1073,10 @@ module.exports.login = async (req, res, next) => { let lockStatus = { locked: false, retryAfterSeconds: 0 }; try { - // Fail-open: if Redis is unavailable, do not block login attempts. lockStatus = await checkLockout(projectId, normalizedEmail); } catch (lockErr) { console.error('[login-lockout] checkLockout failed:', lockErr?.message || lockErr); + return sendLockoutServiceError(); } if (lockStatus.locked) { @@ -1092,10 +1094,10 @@ module.exports.login = async (req, res, next) => { if (!user) { let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; try { - // Fail-open: if Redis is unavailable, do not block login on attempt tracking. failedStatus = await recordFailedAttempt(projectId, normalizedEmail); } catch (attemptErr) { console.error('[login-lockout] recordFailedAttempt failed (user missing):', attemptErr?.message || attemptErr); + return sendLockoutServiceError(); } if (failedStatus.locked) { @@ -1108,10 +1110,10 @@ module.exports.login = async (req, res, next) => { if (!validPass) { let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 }; try { - // Fail-open: if Redis is unavailable, do not block login on attempt tracking. failedStatus = await recordFailedAttempt(projectId, normalizedEmail); } catch (attemptErr) { console.error('[login-lockout] recordFailedAttempt failed (invalid password):', attemptErr?.message || attemptErr); + return sendLockoutServiceError(); } if (failedStatus.locked) { From 4144d7b4375c22977d2c432d04f6384753f04626 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Yadav Date: Sun, 17 May 2026 14:09:07 +0530 Subject: [PATCH 5/6] userAuth: standardize auth error envelope (use AppError/standard API envelope) --- apps/public-api/src/controllers/userAuth.controller.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 0a067e15..5ad40956 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -1060,7 +1060,8 @@ module.exports.login = async (req, res, next) => { if (typeof next === 'function') { return next(new AppError(statusCode, message)); } - return res.status(statusCode).json({ error: message }); + // Fallback for direct responses: use the project's standard API envelope + return res.status(statusCode).json({ success: false, data: {}, message }); }; const sendLockoutServiceError = (message = 'Login lockout service unavailable') => sendAuthError(503, message); From 564302269621b624719b24b218a8a36740ce4904 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Yadav Date: Sun, 17 May 2026 21:15:53 +0530 Subject: [PATCH 6/6] feat(auth): finalize login lockout fixes and exports; standardize error envelope --- packages/common/src/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/common/src/index.js b/packages/common/src/index.js index d3083dd1..59366060 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -24,6 +24,8 @@ const Webhook = require("./models/Webhook"); const WebhookDelivery = require("./models/WebhookDelivery"); const ProRequest = require("./models/ProRequest"); const ApiAnalytics = require("./models/ApiAnalytics"); +const PlatformEvent = require("./models/PlatformEvent"); +const DeveloperActivity = require("./models/DeveloperActivity"); // Queues const { authEmailQueue, initAuthEmailWorker } = require("./queues/authEmailQueue"); @@ -35,6 +37,8 @@ const { initWebhookWorker, generateSignature, } = require("./queues/webhookQueue"); +const { activityRollupQueue, scheduleActivityRollup, initActivityRollupWorker } = require('./queues/activityRollupQueue'); +const { reliabilityAlertQueue, scheduleReliabilityAlert, initReliabilityAlertWorker } = require('./queues/reliabilityAlertQueue'); // Middleware const checkAuthEnabled = require('./middleware/checkAuthEnabled') @@ -179,6 +183,14 @@ module.exports = { AppError, getPresignedUploadUrl, verifyUploadedFile, + PlatformEvent, + DeveloperActivity, + activityRollupQueue, + scheduleActivityRollup, + initActivityRollupWorker, + reliabilityAlertQueue, + scheduleReliabilityAlert, + initReliabilityAlertWorker, ApiAnalytics, checkLockout, recordFailedAttempt,