import { and, eq, gt, isNull, or } from 'drizzle-orm' import { z } from 'zod' import { db } from '../db/client.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js' import { createSession, findAuthenticatedUser } from '../lib/authSession.js' import { env } from '../config.js' const signupSchema = z.object({ email: z.string().trim().email(), password: z.string().min(8).max(72), nickname: z.string().trim().min(2).max(30), }) const loginSchema = z.object({ email: z.string().trim().min(1).max(255), password: z.string().min(1).max(72), }) const profileSchema = z.object({ email: z.string().trim().email(), nickname: z.string().trim().min(2).max(30), }) const passwordSchema = z.object({ currentPassword: z.string().min(1).max(72), newPassword: z.string().min(8).max(72), }) const verificationRequestSchema = z.object({ email: z.string().trim().email().optional(), }) const verificationConfirmSchema = z.object({ token: z.string().trim().min(20).max(255), }) const passwordResetRequestSchema = z.object({ email: z.string().trim().email(), }) const passwordResetConfirmSchema = z.object({ token: z.string().trim().min(20).max(255), newPassword: z.string().min(8).max(72), }) const TOKEN_TTL_MS = 1000 * 60 * 30 function buildPreviewUrl(pathname, token) { const url = new URL(pathname, env.APP_BASE_URL) url.searchParams.set('token', token) return url.toString() } async function createEmailVerificationToken(userId) { const token = createSessionToken() const tokenHash = hashSessionToken(token) const now = new Date() const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS) await db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.userId, userId)) await db.insert(emailVerificationTokens).values({ userId, tokenHash, expiresAt, createdAt: now, }) return { token, previewUrl: buildPreviewUrl('/verify-email', token), } } async function createPasswordResetToken(userId) { const token = createSessionToken() const tokenHash = hashSessionToken(token) const now = new Date() const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS) await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId)) await db.insert(passwordResetTokens).values({ userId, tokenHash, expiresAt, createdAt: now, }) return { token, previewUrl: buildPreviewUrl('/reset-password', token), } } function sanitizeUser(user) { return { id: user.id, email: user.email, loginId: user.loginId, nickname: user.nickname, role: user.role, emailVerifiedAt: user.emailVerifiedAt, lastLoginAt: user.lastLoginAt, createdAt: user.createdAt, updatedAt: user.updatedAt, } } async function findUserByNickname(nickname) { const [user] = await db .select() .from(users) .where(eq(users.nickname, nickname)) .limit(1) return user ?? null } export async function registerAuthRoutes(app) { app.post('/api/auth/signup', async (request, reply) => { const payload = signupSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '회원가입 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const { email, password, nickname } = payload.data const normalizedEmail = email.toLowerCase() const [existingUser] = await db .select() .from(users) .where(eq(users.email, normalizedEmail)) .limit(1) if (existingUser) { return reply.code(409).send({ message: '이미 사용 중인 이메일입니다.', }) } const existingNicknameUser = await findUserByNickname(nickname) if (existingNicknameUser) { return reply.code(409).send({ message: '이미 사용 중인 닉네임입니다.', }) } const now = new Date() const passwordHash = await hashPassword(password) const [user] = await db .insert(users) .values({ email: normalizedEmail, loginId: null, passwordHash, nickname, role: 'user', emailVerifiedAt: null, lastLoginAt: now, createdAt: now, updatedAt: now, }) .returning() const { token } = await createSession(user.id) const verification = await createEmailVerificationToken(user.id) return reply.code(201).send({ message: '회원가입이 완료되었습니다.', token, user: sanitizeUser(user), verificationPreviewUrl: verification.previewUrl, }) }) app.post('/api/auth/login', async (request, reply) => { const payload = loginSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '로그인 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const identifier = payload.data.email.trim() const normalizedEmail = identifier.toLowerCase() const [user] = await db .select() .from(users) .where( or( eq(users.email, normalizedEmail), eq(users.loginId, identifier), ), ) .limit(1) if (!user) { return reply.code(401).send({ message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.', }) } const passwordMatches = await verifyPassword(payload.data.password, user.passwordHash) if (!passwordMatches) { return reply.code(401).send({ message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.', }) } const now = new Date() const [updatedUser] = await db .update(users) .set({ lastLoginAt: now, updatedAt: now, }) .where(eq(users.id, user.id)) .returning() const { token } = await createSession(user.id) return { message: '로그인에 성공했습니다.', token, user: sanitizeUser(updatedUser), } }) app.get('/api/auth/me', async (request, reply) => { const user = await findAuthenticatedUser(request) if (!user) { return reply.code(401).send({ message: '인증이 필요합니다.', }) } return { user: sanitizeUser(user), } }) app.put('/api/auth/profile', async (request, reply) => { const user = await findAuthenticatedUser(request) if (!user) { return reply.code(401).send({ message: '인증이 필요합니다.', }) } const payload = profileSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '프로필 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const normalizedEmail = payload.data.email.toLowerCase() const [existingUser] = await db .select() .from(users) .where(eq(users.email, normalizedEmail)) .limit(1) if (existingUser && existingUser.id !== user.id) { return reply.code(409).send({ message: '이미 사용 중인 이메일입니다.', }) } const existingNicknameUser = await findUserByNickname(payload.data.nickname) if (existingNicknameUser && existingNicknameUser.id !== user.id) { return reply.code(409).send({ message: '이미 사용 중인 닉네임입니다.', }) } const [updatedUser] = await db .update(users) .set({ email: normalizedEmail, nickname: payload.data.nickname, updatedAt: new Date(), }) .where(eq(users.id, user.id)) .returning() return { message: '프로필이 수정되었습니다.', user: sanitizeUser(updatedUser), } }) app.put('/api/auth/password', async (request, reply) => { const user = await findAuthenticatedUser(request) if (!user) { return reply.code(401).send({ message: '인증이 필요합니다.', }) } const payload = passwordSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '비밀번호 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const passwordMatches = await verifyPassword(payload.data.currentPassword, user.passwordHash) if (!passwordMatches) { return reply.code(401).send({ message: '현재 비밀번호가 올바르지 않습니다.', }) } const passwordHash = await hashPassword(payload.data.newPassword) await db .update(users) .set({ passwordHash, updatedAt: new Date(), }) .where(eq(users.id, user.id)) return { message: '비밀번호가 변경되었습니다.', } }) app.post('/api/auth/verification/request', async (request, reply) => { const authenticatedUser = await findAuthenticatedUser(request) const payload = verificationRequestSchema.safeParse(request.body ?? {}) if (!payload.success) { return reply.code(400).send({ message: '이메일 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const normalizedEmail = (payload.data.email || authenticatedUser?.email || '').toLowerCase() if (!normalizedEmail) { return reply.code(400).send({ message: '인증 메일을 받을 이메일이 필요합니다.', }) } const [user] = await db .select() .from(users) .where(eq(users.email, normalizedEmail)) .limit(1) if (!user) { return { message: '입력한 이메일로 인증 안내를 보낼 준비가 되면 처리됩니다.', } } if (user.emailVerifiedAt) { return { message: '이미 이메일 인증이 완료된 계정입니다.', } } const verification = await createEmailVerificationToken(user.id) return { message: '이메일 인증 링크를 준비했습니다.', verificationPreviewUrl: verification.previewUrl, } }) app.post('/api/auth/verification/confirm', async (request, reply) => { const payload = verificationConfirmSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '인증 토큰이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const tokenHash = hashSessionToken(payload.data.token) const [verification] = await db .select() .from(emailVerificationTokens) .where( and( eq(emailVerificationTokens.tokenHash, tokenHash), isNull(emailVerificationTokens.usedAt), gt(emailVerificationTokens.expiresAt, new Date()), ), ) .limit(1) if (!verification) { return reply.code(400).send({ message: '이미 사용했거나 만료된 인증 링크입니다.', }) } const now = new Date() await db .update(users) .set({ emailVerifiedAt: now, updatedAt: now, }) .where(eq(users.id, verification.userId)) await db .update(emailVerificationTokens) .set({ usedAt: now, }) .where(eq(emailVerificationTokens.id, verification.id)) return { message: '이메일 인증이 완료되었습니다.', } }) app.post('/api/auth/password-reset/request', async (request, reply) => { const payload = passwordResetRequestSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '이메일 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const normalizedEmail = payload.data.email.toLowerCase() const [user] = await db .select() .from(users) .where(eq(users.email, normalizedEmail)) .limit(1) if (!user) { return { message: '입력한 이메일로 재설정 안내를 보낼 준비가 되면 처리됩니다.', } } const reset = await createPasswordResetToken(user.id) return { message: '비밀번호 재설정 링크를 준비했습니다.', resetPreviewUrl: reset.previewUrl, } }) app.post('/api/auth/password-reset/confirm', async (request, reply) => { const payload = passwordResetConfirmSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '비밀번호 재설정 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const tokenHash = hashSessionToken(payload.data.token) const [resetToken] = await db .select() .from(passwordResetTokens) .where( and( eq(passwordResetTokens.tokenHash, tokenHash), isNull(passwordResetTokens.usedAt), gt(passwordResetTokens.expiresAt, new Date()), ), ) .limit(1) if (!resetToken) { return reply.code(400).send({ message: '이미 사용했거나 만료된 재설정 링크입니다.', }) } const now = new Date() const passwordHash = await hashPassword(payload.data.newPassword) await db .update(users) .set({ passwordHash, updatedAt: now, }) .where(eq(users.id, resetToken.userId)) await db .update(passwordResetTokens) .set({ usedAt: now, }) .where(eq(passwordResetTokens.id, resetToken.id)) await db.delete(authSessions).where(eq(authSessions.userId, resetToken.userId)) return { message: '비밀번호가 재설정되었습니다. 다시 로그인해 주세요.', } }) }