auth/src/services/otpService.js

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,
};