헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 13:34:21 +09:00
parent 996965740f
commit 6a059a9a59
22 changed files with 984 additions and 34 deletions

View File

@@ -1,7 +1,17 @@
import { getMemberBootstrapState } from '../../repositories/member-repository'
import { isResendConfigured } from '../../utils/resend-mail'
/**
* 최초 관리자 등록 필요 여부를 조회한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean, emailOtpConfigured: boolean }>} 부트스트랩 상태
*/
export default defineEventHandler(async () => getMemberBootstrapState())
export default defineEventHandler(async () => {
const base = await getMemberBootstrapState()
const config = useRuntimeConfig()
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
const emailOtpConfigured = isResendConfigured(config) && hasPepper
return {
...base,
emailOtpConfigured
}
})

View File

@@ -0,0 +1,164 @@
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,
invalidatePendingOtpChallenges
} from '../../../repositories/email-otp-repository'
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
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 = String(config.emailOtpPepper || config.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) || '')
await invalidatePendingOtpChallenges(sql, email, purpose)
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>`
await sendResendEmail({
apiKey: String(config.resendApiKey).trim(),
from: String(config.resendFromEmail).trim(),
to: email,
subject,
html
})
return {
ok: true,
message: purpose === 'signup'
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
}
})

View File

@@ -25,7 +25,8 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const user = await getUserByEmail(body.email)
const emailNorm = body.email.trim().toLowerCase()
const user = await getUserByEmail(emailNorm)
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
throw createError({

View File

@@ -0,0 +1,65 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { updateMemberPasswordByEmail } from '../../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository'
const bodySchema = z.object({
email: z.string().trim().email(),
code: z.string().regex(/^\d{6}$/),
newPassword: z.string().min(8).max(32)
})
/**
* 이메일 OTP로 비밀번호를 재설정한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: boolean }>}
*/
export default defineEventHandler(async (event) => {
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
message: '요청 형식이 올바르지 않습니다.'
})
}
const config = useRuntimeConfig()
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
if (!pepper) {
throw createError({
statusCode: 500,
message: '서버 인증 설정이 올바르지 않습니다.'
})
}
const email = parsed.data.email.trim().toLowerCase()
const verify = await verifyAndConsumeEmailOtp({
email,
purpose: 'password_reset',
code: parsed.data.code,
pepper
})
if (!verify.ok) {
throw createError({
statusCode: 400,
message: '인증번호가 올바르지 않거나 만료되었습니다.'
})
}
const nextHash = await bcrypt.hash(parsed.data.newPassword, 12)
const updated = await updateMemberPasswordByEmail({
email,
passwordHash: nextHash
})
if (!updated) {
throw createError({
statusCode: 400,
message: '계정을 찾을 수 없습니다. 다시 인증번호를 요청해 주세요.'
})
}
return { ok: true }
})

View File

@@ -1,16 +1,33 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createUser, getUserByEmail, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { createUser, getUserByEmail, getMemberBootstrapState, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repository'
import { setMemberSession } from '../../utils/member-auth'
import { setAdminSession } from '../../utils/admin-auth'
import { isResendConfigured } from '../../utils/resend-mail'
const signupSchema = z.object({
username: z.string().trim().min(1),
email: z.string().trim().email(),
password: z.string().min(8).max(32)
password: z.string().min(8).max(32),
emailOtp: z.string().regex(/^\d{6}$/).optional()
})
/**
* 이메일 OTP가 회원가입에 필요한지
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
* @param {{ needsAdminSetup: boolean }} bootstrap - 부트스트랩 상태
* @returns {boolean}
*/
const isSignupOtpRequired = (config, bootstrap) => {
if (bootstrap.needsAdminSetup) {
return false
}
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
return isResendConfigured(config) && hasPepper
}
/**
* 회원 가입 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -27,6 +44,11 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const config = useRuntimeConfig()
const bootstrap = await getMemberBootstrapState()
const otpRequired = isSignupOtpRequired(config, bootstrap)
const emailNorm = body.email.trim().toLowerCase()
const usernameTaken = await isUsernameTaken({
username: body.username
})
@@ -38,7 +60,7 @@ export default defineEventHandler(async (event) => {
})
}
const existingUser = await getUserByEmail(body.email)
const existingUser = await getUserByEmail(emailNorm)
if (existingUser) {
throw createError({
@@ -47,10 +69,33 @@ export default defineEventHandler(async (event) => {
})
}
if (otpRequired) {
const otp = body.emailOtp
if (!otp) {
throw createError({
statusCode: 400,
message: '이메일 인증번호를 입력해 주세요.'
})
}
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
const verify = await verifyAndConsumeEmailOtp({
email: emailNorm,
purpose: 'signup',
code: otp,
pepper
})
if (!verify.ok) {
throw createError({
statusCode: 400,
message: '이메일 인증번호가 올바르지 않거나 만료되었습니다.'
})
}
}
const passwordHash = await bcrypt.hash(body.password, 12)
const created = await createUser({
username: body.username,
email: body.email,
username: body.username.trim(),
email: emailNorm,
passwordHash
})
@@ -74,4 +119,3 @@ export default defineEventHandler(async (event) => {
isAdmin: Boolean(created.isAdmin)
}
})