사용자 로그인, 가입 처리
CI / test (push) Successful in 3m40s
CI / pr_and_merge (push) Successful in 1m8s

This commit is contained in:
2025-11-30 23:08:38 +09:00
parent 25162e4c12
commit 64e4a59244
82 changed files with 2817 additions and 28 deletions
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { verifyPassword, signAccessToken } from "@/features/auth/authCrypto";
// /api/auth/login
// - 이메일/비밀번호를 검증해 로그인시키고,
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
const prisma = new PrismaClient();
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null as any);
const emailRaw = body?.email;
const password: string | undefined = body?.password;
if (typeof emailRaw !== "string" || typeof password !== "string") {
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
}
const email = emailRaw.trim().toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
}
const ok = await verifyPassword(password, user.passwordHash);
if (!ok) {
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
}
const token = await signAccessToken({ id: user.id, email: user.email, tokenVersion: user.tokenVersion });
const { passwordHash: _ph, ...safeUser } = user;
const res = NextResponse.json(safeUser, { status: 200 });
res.cookies.set("pb_access", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
return res;
} catch (error: any) {
return NextResponse.json(
{ message: "로그인 처리 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
// /api/auth/logout
// - 클라이언트의 pb_access 세션 쿠키를 제거해 로그아웃을 수행한다.
export async function POST(_request: Request) {
const res = NextResponse.json({ message: "로그아웃 되었습니다." }, { status: 200 });
// pb_access 쿠키를 즉시 만료시켜 로그아웃 상태로 만든다.
res.cookies.set("pb_access", "", {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return res;
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { verifyAccessToken } from "@/features/auth/authCrypto";
// /api/auth/me
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
export async function GET(request: Request) {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) {
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
}
const payload = await verifyAccessToken(token);
if (!payload) {
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
}
const user = {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
return NextResponse.json(user, { status: 200 });
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
// /api/auth/signup
// - 이메일/비밀번호를 받아 새 유저를 생성하고,
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
const prisma = new PrismaClient();
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null as any);
const emailRaw = body?.email;
const password: string | undefined = body?.password;
if (typeof emailRaw !== "string" || typeof password !== "string") {
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
}
const email = emailRaw.trim().toLowerCase();
if (password.length < 8) {
return NextResponse.json({ message: "비밀번호는 최소 8자 이상이어야 합니다." }, { status: 400 });
}
// 이미 존재하는 이메일인지 확인한다.
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ message: "이미 가입된 이메일입니다." }, { status: 409 });
}
// 비밀번호를 해시한 뒤 유저를 생성한다.
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
email,
passwordHash,
tokenVersion: 1,
},
});
const token = await signAccessToken({
id: user.id,
email: user.email,
tokenVersion: user.tokenVersion,
});
const { passwordHash: _ph, ...safeUser } = user;
const res = NextResponse.json(safeUser, { status: 201 });
res.cookies.set("pb_access", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
return res;
} catch (error: any) {
return NextResponse.json(
{ message: "회원가입 처리 중 오류가 발생했습니다.", error: error?.message },
{ status: 500 },
);
}
}
+60 -3
View File
@@ -1,16 +1,60 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
const prisma = new PrismaClient();
export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project) {
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
@@ -18,12 +62,25 @@ export async function GET(_request: Request, context: { params: { slug: string }
}
export async function DELETE(
_request: Request,
request: Request,
context: { params: { slug: string } | Promise<{ slug: string }> },
) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 삭제하려면 로그인이 필요합니다." }, { status: 401 });
}
const { slug } = await context.params;
try {
const project = await prisma.project.findUnique({
where: { slug },
});
if (!project || (project.userId && project.userId !== authUser.id)) {
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
}
await prisma.project.delete({
where: { slug },
});
+60 -1
View File
@@ -1,11 +1,53 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@/generated/prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
const prisma = new PrismaClient();
export async function GET(_request: Request) {
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }, { status: 401 });
}
try {
const projects = await prisma.project.findMany({
where: { userId: authUser.id },
orderBy: { createdAt: "desc" },
take: 50,
select: {
@@ -28,6 +70,11 @@ export async function GET(_request: Request) {
}
export async function POST(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json({ message: "프로젝트를 저장하려면 로그인이 필요합니다." }, { status: 401 });
}
const body = await request.json();
const { title, slug, contentJson } = body;
@@ -36,6 +83,17 @@ export async function POST(request: Request) {
}
try {
const existing = await prisma.project.findUnique({
where: { slug },
});
if (existing && existing.userId && existing.userId !== authUser.id) {
return NextResponse.json(
{ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." },
{ status: 409 },
);
}
const project = await prisma.project.upsert({
where: { slug },
update: {
@@ -46,6 +104,7 @@ export async function POST(request: Request) {
title,
slug,
contentJson,
userId: authUser.id,
},
});
+34
View File
@@ -3,6 +3,7 @@
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
import {
DndContext,
@@ -70,6 +71,7 @@ import { PropertiesSidebar } from "./panels/PropertiesSidebar";
import { EditorCanvas } from "./EditorCanvas";
export default function EditorPage() {
const router = useRouter();
const blocks = useEditorStore((state) => state.blocks);
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
@@ -138,6 +140,27 @@ export default function EditorPage() {
const canUndo = historyLength > 0;
const canRedo = futureLength > 0;
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!res.ok && res.status === 401 && !cancelled) {
router.push("/login");
}
} catch {
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
@@ -286,6 +309,17 @@ export default function EditorPage() {
});
if (!response.ok) {
if (response.status === 409) {
try {
const data = await response.json();
if (data && typeof (data as any).message === "string") {
setProjectMessage((data as any).message as string);
return;
}
} catch {
}
}
setProjectMessage("프로젝트 저장에 실패했습니다.");
return;
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
// 로그인 페이지 컴포넌트
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
}
} catch {
// ignore
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
try {
const data = await res.json();
setError(data?.message ?? "로그인에 실패했습니다. 다시 시도해 주세요.");
} catch {
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
}
setLoading(false);
return;
}
// 성공 시에는 /projects 로 이동한다.
router.push("/projects");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
<h1 className="text-lg font-semibold mb-1"></h1>
<p className="text-xs text-slate-400 mb-4"> .</p>
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
<div className="space-y-1">
<label htmlFor="email" className="block text-slate-200">
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-200">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
>
{loading ? "로그인 중..." : "로그인"}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-400">
{" "}
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
</Link>
.
</p>
</div>
</main>
);
}
+23
View File
@@ -3,15 +3,38 @@
import type { CSSProperties } from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEditorStore } from "@/features/editor/state/editorStore";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
export default function PreviewPage() {
const router = useRouter();
const blocks = useEditorStore((state) => state.blocks);
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!res.ok && res.status === 401 && !cancelled) {
router.push("/login");
}
} catch {
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
useEffect(() => {
if (typeof window === "undefined") return;
+59 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
FilePlus2,
Trash2,
@@ -22,8 +23,10 @@ interface ProjectListItem {
}
export default function ProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<ProjectListItem[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
@@ -34,11 +37,21 @@ export default function ProjectsPage() {
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch("/api/projects");
if (!res.ok) {
if (!cancelled) {
setStatus("error");
if (res.status === 401) {
setErrorMessage("프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.");
router.push("/login");
} else {
setErrorMessage(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
}
}
return;
}
@@ -47,10 +60,12 @@ export default function ProjectsPage() {
if (!cancelled) {
setProjects(Array.isArray(data) ? data : []);
setStatus("idle");
setErrorMessage(null);
}
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
}
};
@@ -83,6 +98,7 @@ export default function ProjectsPage() {
if (!res.ok) {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
@@ -93,8 +109,31 @@ export default function ProjectsPage() {
window.localStorage.removeItem(`pb:project:${slug}`);
window.localStorage.removeItem(`pb:autosave:${slug}`);
}
setStatus("idle");
setErrorMessage(null);
} catch {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
setStatus("error");
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
router.push("/login");
} catch {
setStatus("error");
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
@@ -127,6 +166,7 @@ export default function ProjectsPage() {
if (okSlugs.length === 0) {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
@@ -142,9 +182,16 @@ export default function ProjectsPage() {
if (okSlugs.length !== slugs.length) {
setStatus("error");
setErrorMessage(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
);
} else {
setStatus("idle");
setErrorMessage(null);
}
} catch {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
@@ -156,6 +203,15 @@ export default function ProjectsPage() {
<p className="text-xs text-slate-400"> </p>
</div>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
onClick={() => {
void handleLogout();
}}
>
</button>
<Link
href="/editor"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
@@ -167,7 +223,9 @@ export default function ProjectsPage() {
</header>
<section className="flex-1 px-6 py-4 overflow-auto">
{status === "error" && (
<p className="text-xs text-red-300 mb-3"> . .</p>
<p className="text-xs text-red-300 mb-3">
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
</p>
)}
{projects.length === 0 && status === "idle" && (
<p className="text-xs text-slate-400"> . .</p>
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
// 회원가입 페이지 컴포넌트
// - 이메일/비밀번호를 입력받아 /api/auth/signup 으로 요청을 전송한다.
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
export default function SignupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
}
} catch {
// ignore
}
};
void checkAuth();
return () => {
cancelled = true;
};
}, [router]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
try {
const data = await res.json();
setError(data?.message ?? "회원가입에 실패했습니다. 다시 시도해 주세요.");
} catch {
setError("회원가입에 실패했습니다. 다시 시도해 주세요.");
}
setLoading(false);
return;
}
router.push("/projects");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
<h1 className="text-lg font-semibold mb-1"></h1>
<p className="text-xs text-slate-400 mb-4"> .</p>
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
<div className="space-y-1">
<label htmlFor="email" className="block text-slate-200">
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-slate-200">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
>
{loading ? "회원가입 중..." : "회원가입"}
</button>
</form>
<p className="mt-4 text-[11px] text-slate-400">
{" "}
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
</Link>
.
</p>
</div>
</main>
);
}
+146
View File
@@ -0,0 +1,146 @@
import bcrypt from "bcryptjs";
import jwt, { JwtPayload } from "jsonwebtoken";
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "crypto";
// 인증/보안 관련 공통 유틸리티 함수들을 제공한다.
// - 비밀번호 해시/검증 (bcrypt 기반)
// - JWT 액세스 토큰 발급/검증
// - 민감정보 JSON 암호화/복호화 (AES-256-GCM)
export interface AccessTokenPayload extends JwtPayload {
sub: string;
email: string;
tokenVersion: number;
}
export interface JwtUserLike {
id: string;
email: string;
tokenVersion: number;
}
// 내부에서 사용할 JWT 시크릿을 가져온다.
// - 설정되지 않은 경우에는 명확한 에러를 던져 조기에 문제를 발견한다.
function getJwtSecret(): string {
const secret = process.env.AUTH_JWT_SECRET;
if (!secret || secret.length === 0) {
throw new Error("AUTH_JWT_SECRET 환경변수가 설정되지 않았습니다.");
}
return secret;
}
// 암호화 키 원본 문자열로부터 32바이트 AES 키를 파생한다.
// - 키 문자열 길이에 관계없이 SHA-256 해시를 사용해 32바이트를 만든다.
function deriveEncryptionKey(): Buffer {
const raw = process.env.AUTH_ENCRYPTION_KEY;
if (!raw || raw.length === 0) {
throw new Error("AUTH_ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.");
}
return createHash("sha256").update(raw).digest();
}
// 최소 비밀번호 길이 (보안 요구사항: 8자 이상)
const MIN_PASSWORD_LENGTH = 8;
// 비밀번호를 bcrypt 해시로 변환한다.
export async function hashPassword(plain: string): Promise<string> {
if (typeof plain !== "string" || plain.length < MIN_PASSWORD_LENGTH) {
throw new Error("비밀번호는 최소 8자 이상이어야 합니다.");
}
// bcryptjs 기본 라운드 수(10)를 사용한다. 필요 시 환경에 맞게 조정 가능.
const saltRounds = 10;
return await bcrypt.hash(plain, saltRounds);
}
// 평문 비밀번호와 저장된 해시가 일치하는지 검증한다.
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
if (!hash) return false;
if (!plain || plain.length < MIN_PASSWORD_LENGTH) {
// 너무 짧은 비밀번호는 즉시 false 를 반환해 타이밍 공격을 줄인다.
return false;
}
return await bcrypt.compare(plain, hash);
}
// 유저 정보를 기반으로 JWT 액세스 토큰을 발급한다.
export async function signAccessToken(user: JwtUserLike): Promise<string> {
const secret = getJwtSecret();
const payload: AccessTokenPayload = {
sub: user.id,
email: user.email,
tokenVersion: user.tokenVersion,
};
// 만료 시간은 기본 15분으로 설정한다.
const token = jwt.sign(payload, secret, {
algorithm: "HS256",
expiresIn: "15m",
});
return token;
}
// JWT 액세스 토큰을 검증하고, 유효하면 페이로드를 반환한다.
// - 검증 실패(시그니처 오류, 만료 등) 시 null 을 반환한다.
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload | null> {
const secret = getJwtSecret();
try {
const decoded = jwt.verify(token, secret) as AccessTokenPayload;
if (!decoded || typeof decoded.sub !== "string") {
return null;
}
return decoded;
} catch {
return null;
}
}
// JSON 객체를 AES-256-GCM 으로 암호화한다.
// - 결과는 iv.tag.ciphertext 형태의 base64 조합 문자열이다.
export async function encryptJson<T extends object>(value: T): Promise<string> {
const key = deriveEncryptionKey();
const iv = randomBytes(12); // GCM 권장 IV 길이 96bit
const plain = JSON.stringify(value);
const cipher = createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plain, "utf8", "base64");
encrypted += cipher.final("base64");
const authTag = cipher.getAuthTag();
const ivB64 = iv.toString("base64");
const tagB64 = authTag.toString("base64");
return `${ivB64}.${tagB64}.${encrypted}`;
}
// AES-256-GCM 으로 암호화된 JSON 문자열을 복호화한다.
export async function decryptJson<T = unknown>(ciphertext: string): Promise<T> {
const key = deriveEncryptionKey();
try {
const parts = ciphertext.split(".");
if (parts.length !== 3) {
throw new Error("잘못된 암호문 형식입니다.");
}
const [ivB64, tagB64, dataB64] = parts;
const iv = Buffer.from(ivB64, "base64");
const authTag = Buffer.from(tagB64, "base64");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(dataB64, "base64", "utf8");
decrypted += decipher.final("utf8");
return JSON.parse(decrypted) as T;
} catch (error) {
// 복호화 실패 시 명시적인 에러를 던져 상위에서 처리할 수 있게 한다.
throw new Error("민감정보 복호화에 실패했습니다.");
}
}