174 lines
5.6 KiB
JavaScript
174 lines
5.6 KiB
JavaScript
import { randomBytes } from 'node:crypto'
|
|
import { z } from 'zod'
|
|
import { createError, getRequestIP, 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'
|
|
|
|
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 = String(getRequestIP(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 = String(getRequestIP(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'
|
|
? `<p>회원가입을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
|
: `<p>비밀번호 재설정을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
|
|
|
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'
|
|
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
|
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
|
}
|
|
})
|