auth/src/services/otpService.js

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