Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/api/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
58 changes: 58 additions & 0 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -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<LoginSuccessResponse>();

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<string, unknown>);

return NextResponse.json({ message: errorData.message || "로그인 실패" }, { status });
}

return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 });
}
}
13 changes: 13 additions & 0 deletions app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
70 changes: 70 additions & 0 deletions app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -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<RefreshSuccessResponse>();

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<string, unknown>);

return NextResponse.json({ message: errorData.message || "토큰 재발급 실패" }, { status });
}

return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 });
}
}
61 changes: 61 additions & 0 deletions app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
@@ -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<SignupResponse>();

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<string, unknown>);
return NextResponse.json({ message: errorData.message || "회원가입 실패" }, { status });
}
return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 });
}
}
32 changes: 32 additions & 0 deletions middleware.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 로그인이 필요한 페이지로 접근했는데, 인증된 사용자가 아닌경우 로그인 페이지로 리다이렉트 되고,
로그인을 완료하면 이전에 접속했던 페이지로 이동하는 기능을 도입하면 좋을것 같아요

보통 리액트에서는 저는 ?redirect=/mypage 와 같은 방식으로 구현하는데,
NextJS 에서는 어떻게 처리하는게 좋을까요?
discussion 에서 한번 얘기해봐도 좋겠네요 @eunwoo-levi @kimgho

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

middleware.ts 코드에 대해서 설명이 부족했네요!

추후 로그인이 필요한 페이지로 접근했는데, 인증된 사용자가 아닌경우 로그인 페이지로 리다이렉트 되고,

에 대한 로직은 아래 코드 입니다!

const PUBLIC_ONLY = ['/login', '/signup']; 에 해당되는 페이지 경로들은

const accessToken = request.cookies.get('accessToken')?.value;
const isAuthed = Boolean(accessToken); 

즉, 권한이 없을 경우 (인증된 사용자X) 로그인으로 리다이렉트 됩니다.

  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);
  }

위 코드에서 로그인을 성공했을 때, url.searchParams.set('next', pathname); 코드에 의해 이전에 사용자가 위치했던 경로로 리다이렉트 됩니다!

Original file line number Diff line number Diff line change
@@ -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*"],
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/shared/lib/apiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import ky from 'ky';

export const apiClient = ky.create({
prefixUrl: '/api',
credentials: 'include',
});
8 changes: 8 additions & 0 deletions src/shared/lib/apiServer.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://nextjs-ko.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment

Nextjs 공식문서를 최근에 공부중인데 server-only 라는 패키지를 사용하면 해당 번들이 서버에서만 사용할 수 있도록 빌드타임에 오류를 띄워준다고 해요

해당 패키지 사용하는거 어떻게 생각하시나요?
@eunwoo-levi @kimgho

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. 좋은 정보 감사합니다! 해당 내용은 discussion에서 같이 의논해도 좋을 것 같아요!

Original file line number Diff line number Diff line change
@@ -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
})

13 changes: 13 additions & 0 deletions src/shared/types/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type SuccessResponse<T> = {
status: "SUCCESS";
message: string;
data: T;
};

export type ErrorResponse = {
status: "ERROR";
message: string;
data: null;
};

export type BaseResponse<T> = SuccessResponse<T> | ErrorResponse;
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading