import { createError } from 'h3' import { getPostgresClient } from './postgres-client' import { hashOtpCode, normalizeOtpEmail, timingSafeEqualHex } from '../utils/email-otp' /** 최대 검증 시도 횟수(초과 시 해당 챌린지는 더 이상 사용 불가) */ const MAX_OTP_VERIFY_ATTEMPTS = 8 /** * DB 클라이언트 조회 (필수) * @returns {ReturnType} postgres sql */ const requireSql = () => { const sql = getPostgresClient() if (!sql) { throw createError({ statusCode: 500, message: '데이터베이스 설정이 필요합니다.' }) } return sql } /** * 동일 이메일·용도의 미소진 OTP를 무효화한다. * @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션 * @param {string} email - 정규화된 이메일 * @param {string} purpose - signup | password_reset * @returns {Promise} */ export const invalidatePendingOtpChallenges = async (sql, email, purpose) => { await sql` UPDATE email_otp_challenges SET consumed_at = now() WHERE lower(email) = lower(${email}) AND purpose = ${purpose} AND consumed_at IS NULL ` } /** * 특정 OTP를 제외한 동일 이메일·용도의 미소진 OTP를 무효화한다. * @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션 * @param {string} email - 정규화된 이메일 * @param {string} purpose - signup | password_reset * @param {string} keepId - 유지할 챌린지 ID * @returns {Promise} */ export const invalidatePendingOtpChallengesExcept = async (sql, email, purpose, keepId) => { await sql` UPDATE email_otp_challenges SET consumed_at = now() WHERE lower(email) = lower(${email}) AND purpose = ${purpose} AND id <> ${keepId} AND consumed_at IS NULL ` } /** * 특정 OTP 챌린지를 삭제한다. * @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션 * @param {string} id - 챌린지 ID * @returns {Promise} */ export const deleteOtpChallengeById = async (sql, id) => { await sql` DELETE FROM email_otp_challenges WHERE id = ${id} ` } /** * 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다. * @param {import('postgres').Sql} sql - sql * @param {string} email - 이메일 * @param {string} purpose - 용도 * @returns {Promise} true면 재요청 쿨다운 중 */ export const hasRecentOtpSend = async (sql, email, purpose) => { const rows = await sql` SELECT 1 AS "x" FROM email_otp_challenges WHERE lower(email) = lower(${email}) AND purpose = ${purpose} AND created_at > now() - interval '55 seconds' LIMIT 1 ` return Boolean(rows?.[0]) } /** * 1시간 내 발송 횟수 * @param {import('postgres').Sql} sql - sql * @param {string} email - 이메일 * @param {string} purpose - 용도 * @returns {Promise} */ export const countOtpSendsLastHour = async (sql, email, purpose) => { const rows = await sql` SELECT COUNT(*)::int AS "c" FROM email_otp_challenges WHERE lower(email) = lower(${email}) AND purpose = ${purpose} AND created_at > now() - interval '1 hour' ` return Number(rows?.[0]?.c || 0) } /** * OTP 챌린지 행을 삽입한다. * @param {import('postgres').Sql} sql - sql * @param {{ email: string, purpose: string, codeHash: string, expiresAt: Date, createdIp: string }} input - 입력 * @returns {Promise} 삽입된 id */ export const insertOtpChallenge = async (sql, input) => { const rows = await sql` INSERT INTO email_otp_challenges (email, purpose, code_hash, expires_at, created_ip) VALUES (${input.email}, ${input.purpose}, ${input.codeHash}, ${input.expiresAt}, ${input.createdIp}) RETURNING id ` const id = rows?.[0]?.id if (!id) { throw createError({ statusCode: 500, message: '인증 정보 저장에 실패했습니다.' }) } return String(id) } /** * 이메일 OTP를 검증하고 소진 처리한다. * @param {{ email: string, purpose: string, code: string, pepper: string }} input - 입력 * @returns {Promise<{ ok: boolean, reason?: 'none' | 'expired' | 'locked' | 'mismatch' }>} */ export const verifyAndConsumeEmailOtp = async (input) => { const sql = requireSql() const email = normalizeOtpEmail(input.email) const purpose = String(input.purpose || '').trim() const code = String(input.code || '').trim() const pepper = String(input.pepper || '') if (!email || !purpose || !/^\d{6}$/.test(code) || !pepper) { return { ok: false, reason: 'mismatch' } } return await sql.begin(async (tx) => { const rows = await tx` SELECT id, code_hash AS "codeHash", verify_attempt_count AS "verifyAttemptCount", expires_at AS "expiresAt" FROM email_otp_challenges WHERE lower(email) = lower(${email}) AND purpose = ${purpose} AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1 FOR UPDATE ` const row = rows?.[0] if (!row) { return { ok: false, reason: 'none' } } const expiresAt = new Date(row.expiresAt) if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() < Date.now()) { await tx` UPDATE email_otp_challenges SET consumed_at = now() WHERE id = ${row.id} ` return { ok: false, reason: 'expired' } } if (Number(row.verifyAttemptCount || 0) >= MAX_OTP_VERIFY_ATTEMPTS) { return { ok: false, reason: 'locked' } } const expected = hashOtpCode({ pepper, email, purpose, code }) if (!timingSafeEqualHex(expected, row.codeHash)) { await tx` UPDATE email_otp_challenges SET verify_attempt_count = verify_attempt_count + 1 WHERE id = ${row.id} ` return { ok: false, reason: 'mismatch' } } await tx` UPDATE email_otp_challenges SET consumed_at = now() WHERE id = ${row.id} ` return { ok: true } }) }