172 lines
5.3 KiB
JavaScript
172 lines
5.3 KiB
JavaScript
// 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,
|
|
};
|