diff --git a/app/backend/src/lib/otp.ts b/app/backend/src/lib/otp.ts new file mode 100644 index 0000000..5e25cca --- /dev/null +++ b/app/backend/src/lib/otp.ts @@ -0,0 +1,71 @@ +// OTP helpers — 6-digit codes stored in Redis with 5-min TTL. +// SMS sending is a stub until a provider (Kavenegar, SMS.ir, etc.) is configured. + +import { randomInt } from 'node:crypto'; +import { redis } from '../redis.js'; + +const OTP_TTL_SECONDS = 5 * 60; +const OTP_MAX_ATTEMPTS = 3; + +const otpKey = (phone: string) => `otp:${phone}`; +const otpAttemptsKey = (phone: string) => `otp:attempts:${phone}`; + +function generate6(): string { + return String(100000 + randomInt(900000)); +} + +type OtpSendResult = { sent: true; provider: string } | { sent: false; reason: 'sms_not_configured' | 'unsupported_provider' | 'provider_error'; provider?: string }; + +async function sendOtpSms(phone: string, code: string): Promise { + const provider = process.env.SMS_PROVIDER; + if (!provider || !process.env.SMS_API_KEY) { + return { sent: false, reason: 'sms_not_configured' }; + } + if (provider !== 'kavenegar') return { sent: false, reason: 'unsupported_provider', provider }; + + const sender = process.env.SMS_SENDER; + const params = new URLSearchParams({ + receptor: phone, + message: `کد تایید تکناو: ${code}`, + }); + if (sender) params.set('sender', sender); + + try { + const res = await fetch(`https://api.kavenegar.com/v1/${process.env.SMS_API_KEY}/sms/send.json`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }); + if (!res.ok) return { sent: false, reason: 'provider_error', provider }; + return { sent: true, provider }; + } catch { + return { sent: false, reason: 'provider_error', provider }; + } +} + +export async function issueOtp(phone: string): Promise { + const code = generate6(); + const result = await sendOtpSms(phone, code); + if (result.sent) { + await redis.set(otpKey(phone), code, 'EX', OTP_TTL_SECONDS); + await redis.del(otpAttemptsKey(phone)); + } + return result; +} + +export async function verifyOtp(phone: string, code: string): Promise { + const stored = await redis.get(otpKey(phone)); + if (!stored) return false; + + const attempts = Number(await redis.incr(otpAttemptsKey(phone))); + if (attempts === 1) await redis.expire(otpAttemptsKey(phone), OTP_TTL_SECONDS); + if (attempts > OTP_MAX_ATTEMPTS) { + await redis.del(otpKey(phone)); + return false; + } + + if (stored !== code) return false; + await redis.del(otpKey(phone)); + await redis.del(otpAttemptsKey(phone)); + return true; +}