import { randomBytes } from 'node:crypto' import { z } from 'zod' import { createError, readBody } from 'h3' import { getPostgresClient } from '../../../repositories/postgres-client' import { getMemberBootstrapState, getUserByEmail } from '../../../repositories/member-repository' import { countOtpSendsLastHour, hasRecentOtpSend, insertOtpChallenge, deleteOtpChallengeById, invalidatePendingOtpChallenges, invalidatePendingOtpChallengesExcept } from '../../../repositories/email-otp-repository' import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp' import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail' import { getRuntimeEnvValue } from '../../../utils/runtime-env' import { getClientIp } from '../../../utils/request-ip' const bodySchema = z.object({ email: z.string().trim().email(), purpose: z.enum(['signup', 'password_reset']) }) const OTP_TTL_MS = 15 * 60 * 1000 const MAX_SENDS_PER_HOUR = 5 /** * OTP 이메일 발송용 pepper * @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정 * @returns {string} */ const resolveOtpPepper = (config) => { const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim() if (!pepper) { throw createError({ statusCode: 500, message: '이메일 인증을 사용하려면 MEMBER_SESSION_SECRET 또는 EMAIL_OTP_PEPPER를 설정해 주세요.' }) } return pepper } /** * 인증번호 이메일 발송 요청 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise<{ ok: boolean, message: string }>} */ export default defineEventHandler(async (event) => { const config = useRuntimeConfig() if (!isResendConfigured(config)) { throw createError({ statusCode: 503, message: '이메일 발송(Resend)이 서버에 설정되지 않았습니다. RESEND_API_KEY와 RESEND_FROM_EMAIL을 확인해 주세요.' }) } const parsed = bodySchema.safeParse(await readBody(event)) if (!parsed.success) { throw createError({ statusCode: 400, message: '요청 형식이 올바르지 않습니다.' }) } const email = normalizeOtpEmail(parsed.data.email) const purpose = parsed.data.purpose const sql = getPostgresClient() if (!sql) { throw createError({ statusCode: 500, message: '데이터베이스 설정이 필요합니다.' }) } const genericOk = { ok: true, message: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.' } if (purpose === 'signup') { const bootstrap = await getMemberBootstrapState() if (bootstrap.needsAdminSetup) { throw createError({ statusCode: 400, message: '최초 관리자 등록 단계에서는 이메일 인증이 필요하지 않습니다.' }) } const existing = await getUserByEmail(email) if (existing) { throw createError({ statusCode: 409, message: '이미 사용 중인 이메일입니다.' }) } } if (await hasRecentOtpSend(sql, email, purpose)) { throw createError({ statusCode: 429, message: '잠시 후 다시 인증번호를 요청해 주세요.' }) } if ((await countOtpSendsLastHour(sql, email, purpose)) >= MAX_SENDS_PER_HOUR) { throw createError({ statusCode: 429, message: '인증번호 요청 횟수가 너무 많습니다. 1시간 후 다시 시도해 주세요.' }) } if (purpose === 'password_reset') { const user = await getUserByEmail(email) if (!user) { const dummyHash = randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + OTP_TTL_MS) const createdIp = getClientIp(event) await invalidatePendingOtpChallenges(sql, email, purpose) await insertOtpChallenge(sql, { email, purpose, codeHash: dummyHash, expiresAt, createdIp }) return genericOk } } const pepper = resolveOtpPepper(config) const code = generateSixDigitOtp() const codeHash = hashOtpCode({ pepper, email, purpose, code }) const expiresAt = new Date(Date.now() + OTP_TTL_MS) const createdIp = getClientIp(event) const challengeId = await insertOtpChallenge(sql, { email, purpose, codeHash, expiresAt, createdIp }) const siteTitle = String(config.public?.siteTitle || 'sori.studio') const subject = purpose === 'signup' ? `[${siteTitle}] 회원가입 인증번호` : `[${siteTitle}] 비밀번호 재설정 인증번호` const html = purpose === 'signup' ? `

회원가입을 위한 인증번호입니다.

${code}

15분 이내에 입력해 주세요.

` : `

비밀번호 재설정을 위한 인증번호입니다.

${code}

15분 이내에 입력해 주세요.

` try { await sendResendEmail({ apiKey: getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey').trim(), from: getRuntimeEnvValue('RESEND_FROM_EMAIL', 'resendFromEmail').trim(), to: email, subject, html }) } catch (error) { await deleteOtpChallengeById(sql, challengeId) throw error } await invalidatePendingOtpChallengesExcept(sql, email, purpose, challengeId) return { ok: true, message: purpose === 'signup' ? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.' : '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.' } })