-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 인증 Router Handler 및 Middleware 로직 구현 #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
27d0ca7
86d39ac
751cc03
bc445d4
7075018
f1cfb98
440da10
e0242f9
5173d16
5e21b21
d262942
377ef1a
a0c9ffb
7cf50f3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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 }); | ||
| } | ||
| } |
| 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; | ||
| } |
| 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 }); | ||
| } | ||
| } |
| 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 }); | ||
| } | ||
| } |
| 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*"], | ||
| }; |
| 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', | ||
| }); |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nextjs 공식문서를 최근에 공부중인데 해당 패키지 사용하는거 어떻게 생각하시나요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| }) | ||
|
|
| 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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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'];에 해당되는 페이지 경로들은즉, 권한이 없을 경우 (인증된 사용자X) 로그인으로 리다이렉트 됩니다.
에 대한 로직이 아래 코드 입니다!!
위 코드에서 로그인을 성공했을 때,
url.searchParams.set('next', pathname);코드에 의해 이전에 사용자가 위치했던 경로로 리다이렉트 됩니다!