224 lines
8.1 KiB
JavaScript
224 lines
8.1 KiB
JavaScript
// src/services/otpService.js
|
|
const bcrypt = require('bcrypt');
|
|
const db = require('../db');
|
|
const { markActiveOtp } = require('../middleware/rateLimitMiddleware');
|
|
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
|
const { encryptPhoneNumber } = require('../utils/fieldEncryption');
|
|
|
|
// === 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);
|
|
|
|
// === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
|
|
// Pre-computed dummy hash for constant-time comparison when OTP not found
|
|
// This ensures bcrypt.compare() always executes with similar timing
|
|
// Generated once at module load to avoid performance impact
|
|
let dummyOtpHash = null;
|
|
async function getDummyOtpHash() {
|
|
if (!dummyOtpHash) {
|
|
// Generate a dummy hash that will never match any real OTP
|
|
const dummyCode = 'DUMMY_OTP_' + Math.random().toString(36).substring(2, 15) + Date.now();
|
|
dummyOtpHash = await bcrypt.hash(dummyCode, 10);
|
|
}
|
|
return dummyOtpHash;
|
|
}
|
|
|
|
/**
|
|
* Extract country code from phone number (e.g., +91 from +919876543210)
|
|
* @param {string} phoneNumber - Phone number in E.164 format
|
|
* @returns {string} Country code (default: '+91')
|
|
*/
|
|
function extractCountryCode(phoneNumber) {
|
|
if (!phoneNumber || !phoneNumber.startsWith('+')) {
|
|
return '+91'; // Default to India
|
|
}
|
|
// Extract country code (typically 1-3 digits after +)
|
|
const match = phoneNumber.match(/^\+(\d{1,3})/);
|
|
return match ? `+${match[1]}` : '+91';
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const code = generateOtpCode();
|
|
const expiresAt = new Date(Date.now() + OTP_EXPIRY_MS);
|
|
const otpHash = await bcrypt.hash(code, 10);
|
|
|
|
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
|
// Encrypt phone number before storing
|
|
const encryptedPhone = encryptPhoneNumber(phoneNumber);
|
|
const countryCode = extractCountryCode(phoneNumber);
|
|
|
|
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
|
|
// Mark existing OTPs as deleted for this phone number
|
|
// Note: We search by encrypted phone to handle both encrypted and plaintext (backward compatibility)
|
|
await db.query(
|
|
`UPDATE otp_requests
|
|
SET deleted = TRUE
|
|
WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE`,
|
|
[encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility
|
|
);
|
|
|
|
// Insert new OTP with encrypted phone number (using otp_requests table from final_db.sql)
|
|
await db.query(
|
|
`INSERT INTO otp_requests (phone_number, country_code, otp_hash, expires_at, attempt_count, deleted)
|
|
VALUES ($1, $2, $3, $4, 0, FALSE)`,
|
|
[encryptedPhone, countryCode, 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
|
|
*
|
|
* === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
|
|
* This function uses constant-time execution to prevent timing-based attacks:
|
|
* - Always performs bcrypt.compare() even for expired/max attempts cases
|
|
* - Uses dummy hash for "not found" case to maintain constant time
|
|
* - All code paths take similar execution time regardless of outcome
|
|
*/
|
|
async function verifyOtp(phoneNumber, code) {
|
|
// === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION ===
|
|
// Encrypt phone number for search (handles both encrypted and plaintext for backward compatibility)
|
|
const encryptedPhone = encryptPhoneNumber(phoneNumber);
|
|
|
|
const result = await db.query(
|
|
`SELECT id, otp_hash, expires_at, attempt_count, phone_number, consumed_at
|
|
FROM otp_requests
|
|
WHERE (phone_number = $1 OR phone_number = $2)
|
|
AND deleted = FALSE
|
|
AND consumed_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
[encryptedPhone, phoneNumber] // Try both encrypted and plaintext for backward compatibility
|
|
);
|
|
|
|
// === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
|
|
// Always perform bcrypt.compare() to maintain constant execution time
|
|
// Use a dummy hash if OTP not found to prevent timing leaks
|
|
let otpRecord = result.rows[0];
|
|
let isNotFound = false;
|
|
let isExpired = false;
|
|
let isMaxAttempts = false;
|
|
let hashToCompare = null;
|
|
|
|
if (!otpRecord) {
|
|
// OTP not found - use dummy hash to maintain constant time
|
|
// This ensures bcrypt.compare() always executes with similar timing
|
|
isNotFound = true;
|
|
hashToCompare = await getDummyOtpHash();
|
|
otpRecord = {
|
|
id: null,
|
|
otp_hash: hashToCompare, // Dummy hash for constant-time comparison
|
|
expires_at: new Date(Date.now() + 1000), // Future date
|
|
attempt_count: 0,
|
|
};
|
|
} else {
|
|
hashToCompare = otpRecord.otp_hash;
|
|
// Check if expired (but don't return early - continue to bcrypt.compare)
|
|
isExpired = new Date() > new Date(otpRecord.expires_at);
|
|
|
|
// Check if max attempts exceeded (but don't return early - continue to bcrypt.compare)
|
|
isMaxAttempts = otpRecord.attempt_count >= MAX_OTP_ATTEMPTS;
|
|
}
|
|
|
|
// === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
|
|
// Always perform bcrypt.compare() regardless of expiration/attempts status
|
|
// This ensures all code paths take similar time
|
|
// Even if we know the OTP is expired or max attempts exceeded, we still compare
|
|
// to prevent attackers from distinguishing between different failure modes
|
|
const matches = await bcrypt.compare(code, hashToCompare);
|
|
|
|
// === SECURITY HARDENING: TIMING ATTACK PROTECTION ===
|
|
// Determine the actual result after constant-time comparison
|
|
// Priority: not_found > expired > max_attempts > invalid > valid
|
|
if (isNotFound) {
|
|
// OTP not found - return generic error
|
|
return { ok: false, reason: 'not_found' };
|
|
}
|
|
|
|
if (isExpired) {
|
|
// OTP expired - mark as consumed (but we already did bcrypt.compare for constant time)
|
|
await db.query(
|
|
'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1',
|
|
[otpRecord.id]
|
|
);
|
|
return { ok: false, reason: 'expired' };
|
|
}
|
|
|
|
if (isMaxAttempts) {
|
|
// Max attempts exceeded - mark as consumed (but we already did bcrypt.compare for constant time)
|
|
await db.query(
|
|
'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1',
|
|
[otpRecord.id]
|
|
);
|
|
return { ok: false, reason: 'max_attempts' };
|
|
}
|
|
|
|
// Check if code matches (only if not expired and not max attempts)
|
|
if (!matches) {
|
|
// === ADDED FOR OTP ATTEMPT LIMIT ===
|
|
// Increment attempt count
|
|
await db.query(
|
|
'UPDATE otp_requests SET attempt_count = attempt_count + 1 WHERE id = $1',
|
|
[otpRecord.id]
|
|
);
|
|
return { ok: false, reason: 'invalid' };
|
|
}
|
|
|
|
// === ADDED FOR 2-MIN OTP VALIDITY & NO-RESEND ===
|
|
// Mark OTP as consumed once verified to prevent reuse
|
|
// Using consumed_at instead of deleting (matches final_db.sql schema)
|
|
await db.query(
|
|
'UPDATE otp_requests SET consumed_at = NOW() WHERE id = $1',
|
|
[otpRecord.id]
|
|
);
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Clean up expired OTPs (can be called periodically)
|
|
* Marks expired OTPs as consumed instead of deleting (matches final_db.sql schema)
|
|
*/
|
|
async function cleanupExpiredOtps() {
|
|
await db.query(
|
|
`UPDATE otp_requests
|
|
SET consumed_at = NOW()
|
|
WHERE expires_at < NOW()
|
|
AND consumed_at IS NULL
|
|
AND deleted = FALSE`
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
createOtp,
|
|
verifyOtp,
|
|
cleanupExpiredOtps,
|
|
};
|