diff --git a/app/api/auth/constants.ts b/app/api/auth/constants.ts new file mode 100644 index 0000000..064f838 --- /dev/null +++ b/app/api/auth/constants.ts @@ -0,0 +1,9 @@ +/** + * 인증 관련 상수 + */ +// TODO: 백엔드와 동일하게 맞출 것 +/** Access Token 만료 시간 (15분) */ +export const ACCESS_TOKEN_MAX_AGE = 60 * 15; + +/** Refresh Token 만료 시간 (14일) */ +export const REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 14; diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..9b8b46f --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { HTTPError } from "ky"; + +import { apiServer } from "@/shared/lib/apiServer"; +import { BaseResponse } from "@/shared/types/api"; + +import { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE } from "../constants"; + +export type LoginSuccessResponse = BaseResponse<{ + accessToken: string; + refreshToken: string; +}>; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const result = await apiServer.post("auth/login", { json: body }).json(); + + const { accessToken, refreshToken } = result.data ?? {}; + + if (!accessToken || !refreshToken) { + return NextResponse.json({ message: "토큰이 응답에 없습니다." }, { status: 502 }); + } + + const res = NextResponse.json({ message: result.message ?? "로그인 성공" }, { status: 200 }); + + const isProd = process.env.NODE_ENV === "production"; + + res.cookies.set("accessToken", accessToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + + res.cookies.set("refreshToken", refreshToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({}) as Record); + + return NextResponse.json({ message: errorData.message || "로그인 실패" }, { status }); + } + + return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..061ce58 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +export async function POST() { + const res = NextResponse.json({ message: '로그아웃 성공' }, { status: 200 }); + + res.cookies.delete('accessToken'); + res.cookies.delete('refreshToken'); + + // TODO: 2-phase 임시 토큰 삭제 (일단 삭제 로직에 포함) + res.cookies.delete('signupToken'); + + return res; +} diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..8cd233c --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { HTTPError } from "ky"; + +import { apiServer } from "@/shared/lib/apiServer"; +import { BaseResponse } from "@/shared/types/api"; + +import { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE } from "../constants"; + +export type RefreshSuccessResponse = BaseResponse<{ + accessToken: string; + refreshToken?: string; +}>; + +export async function POST(req: NextRequest) { + try { + const refreshToken = req.cookies.get("refreshToken")?.value; + + if (!refreshToken) { + return NextResponse.json({ message: "refreshToken이 없습니다." }, { status: 401 }); + } + + const result = await apiServer + .post("auth/refresh", { json: { refreshToken } }) + .json(); + + const newAccessToken = result.data?.accessToken; + const newRefreshToken = result.data?.refreshToken; + + if (!newAccessToken) { + return NextResponse.json({ message: "accessToken이 응답에 없습니다." }, { status: 502 }); + } + + const res = NextResponse.json( + { message: result.message ?? "토큰 재발급 성공" }, + { status: 200 }, + ); + + const isProd = process.env.NODE_ENV === "production"; + + res.cookies.set("accessToken", newAccessToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + + if (newRefreshToken) { + res.cookies.set("refreshToken", newRefreshToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + } + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({}) as Record); + + return NextResponse.json({ message: errorData.message || "토큰 재발급 실패" }, { status }); + } + + return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 }); + } +} diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..1ad3d3b --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { HTTPError } from "ky"; + +import { apiServer } from "@/shared/lib/apiServer"; +import { BaseResponse } from "@/shared/types/api"; + +import { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE } from "../constants"; + +export type SignupResponse = BaseResponse<{ + accessToken: string; + refreshToken: string; +}>; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const result = await apiServer + .post("auth/signup", { + json: body, + }) + .json(); + + const { accessToken, refreshToken } = result.data ?? {}; + if (!accessToken || !refreshToken) { + return NextResponse.json({ message: "토큰이 응답에 없습니다." }, { status: 502 }); + } + + const res = NextResponse.json({ message: result.message ?? "회원가입 완료" }, { status: 201 }); + + const isProd = process.env.NODE_ENV === "production"; + + res.cookies.set("accessToken", accessToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + + res.cookies.set("refreshToken", refreshToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + + res.cookies.delete("signupToken"); + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({}) as Record); + return NextResponse.json({ message: errorData.message || "회원가입 실패" }, { status }); + } + return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 }); + } +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..78115ae --- /dev/null +++ b/middleware.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +// TODO: PUBLIC 및 PROTECTED 경로들 추후 추가 및 수정 필요 +const PUBLIC_ONLY = ["/login", "/signup"]; +const PROTECTED_PREFIXES = ["/mypage"]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + const accessToken = request.cookies.get("accessToken")?.value; + + const isAuthed = Boolean(accessToken); + + if (PUBLIC_ONLY.includes(pathname) && isAuthed) { + return NextResponse.redirect(new URL("/", request.url)); + } + + const isProtected = PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix)); + if (isProtected && !isAuthed) { + const url = new URL("/login", request.url); + url.searchParams.set("next", pathname); + return NextResponse.redirect(url); + } + + return NextResponse.next(); +} + +// TODO: 추후 추가 될 예정, matcher는 "미들웨어가 실행될 경로"만 최소로 걸기 +export const config = { + matcher: ["/login", "/signup", "/mypage/:path*"], +}; diff --git a/package.json b/package.json index 7da69a7..3cb83d7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "build": "next build", "start": "next start", "lint": "eslint", + "format": "prettier --write .", + "format:check": "prettier --check .", "type-check": "tsc --noEmit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", @@ -21,6 +23,7 @@ "@vanilla-extract/recipes": "^0.5.7", "@vanilla-extract/sprinkles": "^1.6.5", "@vanilla-extract/vite-plugin": "^5.1.3", + "ky": "^1.14.1", "lucide-react": "^0.555.0", "next": "15.5.7", "react": "^19.2.1", diff --git a/src/shared/lib/apiClient.ts b/src/shared/lib/apiClient.ts new file mode 100644 index 0000000..907871d --- /dev/null +++ b/src/shared/lib/apiClient.ts @@ -0,0 +1,6 @@ +import ky from 'ky'; + +export const apiClient = ky.create({ + prefixUrl: '/api', + credentials: 'include', +}); \ No newline at end of file diff --git a/src/shared/lib/apiServer.ts b/src/shared/lib/apiServer.ts new file mode 100644 index 0000000..19b81b4 --- /dev/null +++ b/src/shared/lib/apiServer.ts @@ -0,0 +1,8 @@ +import ky from 'ky'; + +const API_BASE_URL = process.env.API_BASE_URL; + +export const apiServer = ky.create({ + prefixUrl: API_BASE_URL +}) + diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts new file mode 100644 index 0000000..98e1a6b --- /dev/null +++ b/src/shared/types/api.ts @@ -0,0 +1,13 @@ +export type SuccessResponse = { + status: "SUCCESS"; + message: string; + data: T; +}; + +export type ErrorResponse = { + status: "ERROR"; + message: string; + data: null; +}; + +export type BaseResponse = SuccessResponse | ErrorResponse; diff --git a/yarn.lock b/yarn.lock index 39e177a..34f1e81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4627,6 +4627,7 @@ __metadata: eslint-plugin-storybook: "npm:^9.1.16" globals: "npm:^16.5.0" globrex: "npm:^0.1.2" + ky: "npm:^1.14.1" lucide-react: "npm:^0.555.0" next: "npm:15.5.7" prettier: "npm:^3.7.3" @@ -4754,6 +4755,13 @@ __metadata: languageName: node linkType: hard +"ky@npm:^1.14.1": + version: 1.14.1 + resolution: "ky@npm:1.14.1" + checksum: 10c0/21deb9120170ef1f6c3b80b7980fa2202d56bff9a91344b0102ba9f608068064ba74eff29259b83f68002bdcea18e70bfbd7e044e3a2d7df180656fdccf1f4a0 + languageName: node + linkType: hard + "language-subtag-registry@npm:^0.3.20": version: 0.3.23 resolution: "language-subtag-registry@npm:0.3.23"