Files
sori.studio/server/api/auth/signup.post.js
zenn b77f37a94e v1.3.1: 어나운스 바·가입 금지 닉네임·설정 UI 개선
공개 상단 어나운스 바와 관리자 맞춤 설정을 추가하고, 스팸 필터에서 가입 금지 닉네임을 관리·검증한다. POST 설정 읽기 모드 비활성 토글과 설정 내비 아이콘 틀을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 15:50:47 +09:00

126 lines
3.8 KiB
JavaScript

import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, getRequestIP, 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'
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: String(getRequestIP(event) || '')
})
return {
id: created.id,
username: created.username,
email: created.email,
avatarUrl: created.avatarUrl || '',
isAdmin: Boolean(created.isAdmin)
}
})