import bcrypt from 'bcrypt' import { z } from 'zod' import { createError, readBody } from 'h3' 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' import { getRuntimeEnvValue } from '../../utils/runtime-env' import { assertSignupUsernameAllowed } from '../../utils/member-username-policy' import { getClientIp } from '../../utils/request-ip' const signupSchema = z.object({ username: z.string().trim().min(1), email: z.string().trim().email(), 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(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim()) return isResendConfigured(config) && hasPepper } /** * 회원 가입 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean }>} 회원 정보 */ export default defineEventHandler(async (event) => { const parsedBody = signupSchema.safeParse(await readBody(event)) if (!parsedBody.success) { throw createError({ statusCode: 400, message: '회원가입 요청 형식이 올바르지 않습니다.' }) } const body = parsedBody.data const config = useRuntimeConfig() const bootstrap = await getMemberBootstrapState() const otpRequired = isSignupOtpRequired(config, bootstrap) const emailNorm = body.email.trim().toLowerCase() await assertSignupUsernameAllowed(body.username) const usernameTaken = await isUsernameTaken({ username: body.username }) if (usernameTaken) { throw createError({ statusCode: 409, message: '이미 사용 중인 닉네임입니다.' }) } const existingUser = await getUserByEmail(emailNorm) if (existingUser) { throw createError({ statusCode: 409, message: '이미 사용 중인 이메일입니다.' }) } if (otpRequired) { const otp = body.emailOtp if (!otp) { throw createError({ statusCode: 400, message: '이메일 인증번호를 입력해 주세요.' }) } const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', '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.trim(), email: emailNorm, passwordHash }) setMemberSession(event, { userId: created.id, email: created.email }) if (created.isAdmin) { setAdminSession(event, { userId: created.id, email: created.email }) } await touchUserActivity({ userId: created.id, ip: getClientIp(event) }) return { id: created.id, username: created.username, email: created.email, avatarUrl: created.avatarUrl || '', isAdmin: Boolean(created.isAdmin) } })