diff --git a/apps/public-api/src/__tests__/userAuth.email.test.js b/apps/public-api/src/__tests__/userAuth.email.test.js index b520d4bb..79e5f1f4 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'); @@ -238,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({ @@ -333,7 +338,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 +363,62 @@ 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); + }); + + 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', () => { @@ -458,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/__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..5ad40956 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -6,6 +6,8 @@ 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 { 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'); @@ -1004,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'); @@ -1046,11 +1055,34 @@ 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) => { + const sendAuthError = (statusCode, message) => { + if (typeof next === 'function') { + return next(new AppError(statusCode, 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); + try { const project = req.project; const { email, password } = loginSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); + const projectId = String(project._id); + + let lockStatus = { locked: false, retryAfterSeconds: 0 }; + try { + lockStatus = await checkLockout(projectId, normalizedEmail); + } catch (lockErr) { + console.error('[login-lockout] checkLockout failed:', lockErr?.message || lockErr); + return sendLockoutServiceError(); + } + + if (lockStatus.locked) { + return sendAuthError(423, `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 +1092,43 @@ 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) { + 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); + return sendLockoutServiceError(); + } + + if (failedStatus.locked) { + return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); + } + return sendAuthError(400, '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) { + 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); + return sendLockoutServiceError(); + } + + if (failedStatus.locked) { + return sendAuthError(423, `Account temporarily locked. Try again in ${failedStatus.retryAfterSeconds} seconds.`); + } + 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); + } const issuedTokens = await issueAuthTokens({ project, @@ -1389,6 +1454,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/index.js b/packages/common/src/index.js index 3043699f..59366060 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -37,16 +37,8 @@ const { initWebhookWorker, generateSignature, } = require("./queues/webhookQueue"); -const { - activityRollupQueue, - scheduleActivityRollup, - initActivityRollupWorker, -} = require("./queues/activityRollupQueue"); -const { - reliabilityAlertQueue, - scheduleReliabilityAlert, - initReliabilityAlertWorker, -} = require("./queues/reliabilityAlertQueue"); +const { activityRollupQueue, scheduleActivityRollup, initActivityRollupWorker } = require('./queues/activityRollupQueue'); +const { reliabilityAlertQueue, scheduleReliabilityAlert, initReliabilityAlertWorker } = require('./queues/reliabilityAlertQueue'); // Middleware const checkAuthEnabled = require('./middleware/checkAuthEnabled') @@ -106,6 +98,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, @@ -190,7 +183,6 @@ module.exports = { AppError, getPresignedUploadUrl, verifyUploadedFile, - ApiAnalytics, PlatformEvent, DeveloperActivity, activityRollupQueue, @@ -199,4 +191,8 @@ module.exports = { reliabilityAlertQueue, scheduleReliabilityAlert, initReliabilityAlertWorker, + ApiAnalytics, + 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..80e64a95 --- /dev/null +++ b/packages/common/src/utils/loginLockout.js @@ -0,0 +1,113 @@ +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}`; +}; + +// 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 atomicCheckLua = ` +local lockKey = KEYS[1] +local lockoutSeconds = tonumber(ARGV[1]) + +local exists = redis.call('EXISTS', lockKey) +if exists == 0 then + return { 0, 0 } +end + +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) => { + const failureKey = getFailureKey(projectId, email); + const lockKey = getLockKey(projectId, email); + + const rawResult = await redis.eval( + ATOMIC_RECORD_FAILED_ATTEMPT_LUA, + 2, + failureKey, + lockKey, + String(MAX_FAILED_ATTEMPTS), + String(LOCKOUT_SECONDS), + ); + + 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, + retryAfterSeconds: locked ? ttl : 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, +};