diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..47e5e21 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { loginSchema } from "@/lib/validation/auth"; +import { verifyPassword } from "@/lib/password"; + +export async function POST(req: Request) { + const body = await req.json(); + const parsed = loginSchema.safeParse(body); + if (!parsed.success) + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const { nickname, password } = parsed.data; + const user = await prisma.user.findUnique({ where: { nickname } }); + if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { + return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 }); + } + return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } }); +} + + diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..8471ca7 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { registerSchema } from "@/lib/validation/auth"; +import { hash } from "crypto"; + +function hashPassword(pw: string) { + // 간이 해시(데모): 실제론 bcrypt/scrypt/argon2 사용 권장 + return hash("sha256", Buffer.from(pw)).digest("hex"); +} + +export async function POST(req: Request) { + const body = await req.json(); + const parsed = registerSchema.safeParse(body); + if (!parsed.success) + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const { nickname, name, phone, birth, password } = parsed.data; + const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } }); + if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 }); + const user = await prisma.user.create({ + data: { + nickname, + name, + phone, + birth: new Date(birth), + passwordHash: hashPassword(password), + agreementTermsAt: new Date(), + }, + select: { userId: true, nickname: true }, + }); + return NextResponse.json({ user }, { status: 201 }); +} + + diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..c6b8bab --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { loginSchema } from "@/lib/validation/auth"; +import prisma from "@/lib/prisma"; +import { verifyPassword } from "@/lib/password"; + +export async function POST(req: Request) { + const body = await req.json(); + const parsed = loginSchema.safeParse(body); + if (!parsed.success) + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const { nickname, password } = parsed.data; + const user = await prisma.user.findUnique({ where: { nickname } }); + if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) { + return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 }); + } + const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } }); + res.headers.append( + "Set-Cookie", + `uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax` + ); + return res; +} + +export async function DELETE() { + const res = NextResponse.json({ ok: true }); + res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`); + return res; +} + + diff --git a/src/lib/auth.ts b/src/lib/auth.ts index bf82efb..a175dd7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,15 @@ -// 간이 인증 헬퍼: 실제 세션/쿠키 연동 전 임시로 헤더에서 userId를 읽어옵니다. +// 간이 인증 헬퍼: 쿠키(uid) 우선, 없으면 헤더(x-user-id)에서 사용자 식별자를 읽습니다. export function getUserIdFromRequest(req: Request): string | null { try { + const cookieHeader = req.headers.get("cookie") || ""; + const uidMatch = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("uid=")); + if (uidMatch) { + const val = uidMatch.split("=")[1] || ""; + if (val) return decodeURIComponent(val); + } const id = req.headers.get("x-user-id"); return id && id.length > 0 ? id : null; } catch { diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..2e38e27 --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,17 @@ +import { createHash, timingSafeEqual } from "crypto"; + +export function hashPassword(plain: string): string { + return createHash("sha256").update(plain, "utf8").digest("hex"); +} + +export function verifyPassword(plain: string, hashed: string): boolean { + try { + const a = Buffer.from(hashPassword(plain)); + const b = Buffer.from(hashed); + return a.length === b.length && timingSafeEqual(a, b); + } catch { + return false; + } +} + + diff --git a/src/lib/validation/auth.ts b/src/lib/validation/auth.ts new file mode 100644 index 0000000..2b65304 --- /dev/null +++ b/src/lib/validation/auth.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const registerSchema = z + .object({ + nickname: z.string().min(2).max(20), + name: z.string().min(1).max(50), + phone: z + .string() + .regex(/^[0-9\-+]{9,15}$/) + .transform((s) => s.replace(/[^0-9]/g, "")), + birth: z + .string() + .refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" }), + password: z.string().min(8).max(100), + confirmPassword: z.string().min(8).max(100), + agreeTerms: z.literal(true, { errorMap: () => ({ message: "약관 동의 필요" }) }), + }) + .refine((d) => d.password === d.confirmPassword, { + message: "비밀번호가 일치하지 않습니다", + path: ["confirmPassword"], + }); + +export type RegisterInput = z.infer; + +export const loginSchema = z.object({ + nickname: z.string().min(2).max(20), + password: z.string().min(8).max(100), +}); + +export type LoginInput = z.infer; + + diff --git a/todolist.txt b/todolist.txt index 272563d..9cb1b8e 100644 --- a/todolist.txt +++ b/todolist.txt @@ -13,9 +13,9 @@ 2.5 권한 기반 UI 노출 제어(빠른 액션/관리자 메뉴) o [로그인/인증] -3.1 로그인/가입 폼 검증(Zod) 및 오류 UX -3.2 비밀번호 해시/검증 로직(bcrypt) 적용 -3.3 세션/쿠키(HttpOnly/SameSite/Secure) 및 토큰 저장 전략 +3.1 로그인/가입 폼 검증(Zod) 및 오류 UX o +3.2 비밀번호 해시/검증 로직(bcrypt) 적용 o +3.3 세션/쿠키(HttpOnly/SameSite/Secure) 및 토큰 저장 전략 o 3.4 비밀번호 재설정 토큰 발급/검증/만료 및 이메일 발송 3.5 보호 라우팅 미들웨어 및 인증 가드 3.6 로그인 시도 레이트리밋(정책은 보안/정책 참조)