// src/services/otpService.js const bcrypt = require('bcrypt'); const db = require('../db'); const { markActiveOtp } = require('../middleware/rateLimitMiddleware'); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // OTP validity changed from 10 minutes to 2 minutes (120 seconds) const OTP_TTL_SECONDS = parseInt(process.env.OTP_TTL_SECONDS || '120', 10); // 2 minutes const OTP_EXPIRY_MS = OTP_TTL_SECONDS * 1000; const MAX_OTP_ATTEMPTS = Number(process.env.OTP_VERIFY_MAX_ATTEMPTS || process.env.OTP_MAX_ATTEMPTS || 5); let otpTableReadyPromise; function ensureOtpCodesTable() { if (!otpTableReadyPromise) { otpTableReadyPromise = db.query(` CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE IF NOT EXISTS otp_codes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), phone_number VARCHAR(20) NOT NULL, otp_hash VARCHAR(255) NOT NULL, expires_at TIMESTAMPTZ NOT NULL, attempt_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_otp_codes_phone ON otp_codes (phone_number); CREATE INDEX IF NOT EXISTS idx_otp_codes_expires ON otp_codes (expires_at); DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'otp_codes' AND column_name = 'code' ) THEN ALTER TABLE otp_codes RENAME COLUMN code TO otp_hash; END IF; IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'otp_codes' AND column_name = 'verified_at' ) THEN ALTER TABLE otp_codes DROP COLUMN verified_at; END IF; IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'otp_codes' AND column_name = 'attempt_count' ) THEN ALTER TABLE otp_codes ADD COLUMN attempt_count INT NOT NULL DEFAULT 0; END IF; END $$; `); } return otpTableReadyPromise; } /** * Generate a random 6-digit OTP code */ function generateOtpCode() { return Math.floor(100000 + Math.random() * 900000).toString(); } /** * Create and store an OTP for a phone number * @param {string} phoneNumber - The phone number to send OTP to * @returns {Promise<{code: string}>} - The generated OTP code */ async function createOtp(phoneNumber) { await ensureOtpCodesTable(); const code = generateOtpCode(); const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS); const otpHash = await bcrypt.hash(code, 10); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Delete any existing OTPs for this phone number await db.query( 'DELETE FROM otp_codes WHERE phone_number = $1', [phoneNumber] ); // Insert new OTP await db.query( `INSERT INTO otp_codes (phone_number, otp_hash, expires_at, attempt_count) VALUES ($1, $2, $3, 0)`, [phoneNumber, otpHash, expiresAt] ); // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Mark OTP as active in Redis/memory store to prevent resend within 2 minutes try { await markActiveOtp(phoneNumber, OTP_TTL_SECONDS); } catch (err) { console.error('Failed to mark active OTP in rate limiter:', err); // Don't fail OTP creation if rate limiter fails } return { code }; } /** * Verify an OTP code for a phone number * @param {string} phoneNumber - The phone number * @param {string} code - The OTP code to verify * @returns {Promise<{ok: boolean}>} - Whether the OTP is valid */ async function verifyOtp(phoneNumber, code) { await ensureOtpCodesTable(); const result = await db.query( `SELECT id, otp_hash, expires_at, attempt_count FROM otp_codes WHERE phone_number = $1 ORDER BY created_at DESC LIMIT 1`, [phoneNumber] ); // === ADDED FOR OTP ATTEMPT LIMIT === // Generic error message to avoid leaking information if (result.rows.length === 0) { return { ok: false, reason: 'not_found' }; } const otpRecord = result.rows[0]; // Check if expired if (new Date() > new Date(otpRecord.expires_at)) { await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]); return { ok: false, reason: 'expired' }; } // === ADDED FOR OTP ATTEMPT LIMIT === // Check if max attempts exceeded if (otpRecord.attempt_count >= MAX_OTP_ATTEMPTS) { await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]); return { ok: false, reason: 'max_attempts' }; } const matches = await bcrypt.compare(code, otpRecord.otp_hash); // Check if code matches if (!matches) { // === ADDED FOR OTP ATTEMPT LIMIT === // Increment attempt count await db.query( 'UPDATE otp_codes SET attempt_count = attempt_count + 1 WHERE id = $1', [otpRecord.id] ); return { ok: false, reason: 'invalid' }; } // === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND === // Delete OTP once verified to prevent reuse // Also clears the active OTP marker (via deletion, Redis TTL will handle cleanup) await db.query('DELETE FROM otp_codes WHERE id = $1', [otpRecord.id]); return { ok: true }; } /** * Clean up expired OTPs (can be called periodically) */ async function cleanupExpiredOtps() { await ensureOtpCodesTable(); await db.query( 'DELETE FROM otp_codes WHERE expires_at < NOW()' ); } module.exports = { createOtp, verifyOtp, cleanupExpiredOtps, };