Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fd7e6dc
feat(50): ky 설치
Seojunhwan Jun 21, 2025
7ea37cc
chore(50): iron-session 설치
Seojunhwan Jun 24, 2025
e1c5c9b
feat(50): 환경 확인 유틸 추가
Seojunhwan Jun 25, 2025
280d68c
refactor(50): function -> const
Seojunhwan Jun 25, 2025
c1512dd
feat(50): custom exception 추가
Seojunhwan Jun 25, 2025
9f213ce
feat(50): API 클라이언트 및 에러 처리 로직 추가
Seojunhwan Jun 28, 2025
f3b86cd
feat(50): 클라이언트 및 서버 세션 관리 기능 추가
Seojunhwan Jun 28, 2025
dafb9b6
feat(50): 시간 관련 상수 및 토큰 유효 기간 정의 추가
Seojunhwan Jun 28, 2025
0dbe27c
feat(50): 로그인, 리프레시 토큰 발급 및 세션 조회 API 추가
Seojunhwan Jul 1, 2025
821fa04
feat(50): 인증 관련 API 및 쿼리 훅 추가 (로그인, 토큰 재발급, 세션 관리)
Seojunhwan Jul 1, 2025
2c67bfa
fix(50): ApiError의 errorCode 필드를 선택적(optional)로 변경
Seojunhwan Jul 1, 2025
88b49f4
refactor(50): 인증 API 경로 수정 및 주석 개선
Seojunhwan Jul 1, 2025
9fc1e18
feat(50): 인증 콜백 페이지 추가 및 로그인 처리 로직 구현
Seojunhwan Jul 1, 2025
f3952b0
feat(50): 미들웨어 추가하여 인증 및 토큰 갱신 로직 구현
Seojunhwan Jul 1, 2025
5a7b795
refactor(50): 세션 관련 모듈 경로 통합 및 코드 정리
Seojunhwan Jul 1, 2025
bad6fca
refactor(50): OAuth 로그인 URL 요청 함수 이름 변경 및 주석 개선
Seojunhwan Jul 1, 2025
5f7e73c
fix(50): 로그인 요청 시 origin 헤더 추가 및 OAuth 리다이렉션 URL 수정
Seojunhwan Jul 2, 2025
5a993b2
fix(50): 로그인 실패 시 UnauthorizedException 처리 및 에러 메시지 개선
Seojunhwan Jul 2, 2025
4feb0e6
feat(50): 인증 API 및 쿼리 훅 구조 개선, 세션 관리 기능 추가
Seojunhwan Jul 2, 2025
c4b2183
fix(50): 로그인 실패 시 리다이렉션 추가 및 에러 메시지 처리 개선
Seojunhwan Jul 2, 2025
4269b15
fix(50): 공개 경로에 루트 경로 추가
Seojunhwan Jul 2, 2025
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"@tanstack/react-query-devtools": "^5.77.0",
"@vanilla-extract/css": "^1.17.2",
"@vanilla-extract/recipes": "^0.5.7",
"iron-session": "^8.0.4",
"ky": "^1.8.1",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
190 changes: 135 additions & 55 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions src/app/(auth)/_api/auth/auth.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { http, nextHttp } from "@/lib/api/client";

import type {
LoginRequest,
LoginResponse,
ReissueRequest,
ReissueResponse,
} from "./auth.types";

/**
* 백엔드의 /api/auth/login 엔드포인트에 로그인 요청을 보냅니다.
*
* @param {LoginRequest} params - 카카오 인가 코드
* @returns {Promise<LoginResponse>} 로그인 응답 데이터
*/
export const postLogin = async (params: LoginRequest) => {
return await http
.post("api/auth/login", { json: params })
.json<LoginResponse>();
};

/**
* 백엔드의 /api/auth/reissue 엔드포인트에 토큰 재발급 요청을 보냅니다.
*
* @param {ReissueRequest} params - 리프레시 토큰
* @returns {Promise<ReissueResponse>} 재발급된 토큰 데이터
*/
export const postReissue = async (params: ReissueRequest) => {
return await http
.post("api/auth/reissue", { json: params })
.json<ReissueResponse>();
};

/**
* OAuth 제공자의 인증 페이지로 브라우저를 리다이렉트시킵니다.
*
* @description
* 이 함수를 호출하면, 서버로부터 302 리다이렉트 응답을 받아
* OAuth 제공자의 인증 페이지로 즉시 이동합니다.
* 반환 값은 없으며, 호출 즉시 페이지가 전환됩니다.
*/
export const redirectToKakaoOAuthLoginPage = async () => {
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/login/oauth`;
};
Comment thread
Seojunhwan marked this conversation as resolved.

type Information = Omit<LoginResponse, "token">["information"];

/**
* Next.js API Route(/api/auth/login)를 통해 로그인 요청을 보냅니다.
*
* @param {LoginRequest} params - 카카오 인가 코드
* @returns {Promise<Information>} 회원 정보
*/
export const postClientLogin = async (params: Omit<LoginRequest, "origin">) => {
return await nextHttp
.post("api/auth/login", { json: params })
.json<Information>();
};

/**
* Next.js API Route(/api/auth/reissue)를 통해 토큰 재발급 요청을 보냅니다.
*
* @returns {Promise<ReissueResponse>} 재발급된 세션 정보
*/
export const postClientReissue = async () => {
return await nextHttp.post("api/auth/reissue").json<ReissueResponse>();
};

/**
* Next.js API Route(/api/auth/logout)를 통해 세션 삭제(로그아웃) 요청을 보냅니다.
*
* @returns {Promise<void>} 로그아웃 결과
*/
export const deleteClientSession = async () => {
return await nextHttp.delete("api/auth/logout").json();
};
25 changes: 25 additions & 0 deletions src/app/(auth)/_api/auth/auth.queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useMutation } from "@tanstack/react-query";

import {
deleteClientSession,
postClientLogin,
postClientReissue,
} from "./auth.api";

export const useLoginMutation = () => {
return useMutation({
mutationFn: postClientLogin,
});
};

export const useReissueMutation = () => {
return useMutation({
mutationFn: postClientReissue,
});
};

export const useDeleteSessionMutation = () => {
return useMutation({
mutationFn: deleteClientSession,
});
};
24 changes: 24 additions & 0 deletions src/app/(auth)/_api/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type LoginRequest = {
code: string;
origin: string;
};

export type LoginResponse = {
token: {
accessToken: string;
refreshToken: string;
};
information: {
id: number;
isSignUp: boolean;
};
};

export type ReissueRequest = {
refreshToken: string;
};

export type ReissueResponse = {
accessToken: string;
refreshToken: string;
};
12 changes: 12 additions & 0 deletions src/app/(auth)/_api/session/session.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { nextHttp } from "@/lib/api/client";

import type { SessionData } from "./session.types";

/**
* Next.js API Route(/api/session)를 통해 세션 정보를 요청합니다.
*
* @returns {Promise<SessionData>} 세션 정보
*/
export const getSession = async () => {
return await nextHttp.get<SessionData>("api/session").json();
};
14 changes: 14 additions & 0 deletions src/app/(auth)/_api/session/session.queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { queryOptions } from "@tanstack/react-query";

import { getSession } from "./session.api";

const sessionQueryKeys = {
session: ["session"],
};

export const sessionQueries = {
session: queryOptions({
queryKey: sessionQueryKeys.session,
queryFn: getSession,
}),
};
1 change: 1 addition & 0 deletions src/app/(auth)/_api/session/session.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type SessionData } from "@/lib/session";
43 changes: 43 additions & 0 deletions src/app/(auth)/login/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";

import { useLoginMutation } from "@/app/(auth)/_api/auth/auth.queries";
import { clearClientSessionCache } from "@/lib/session";

export default function AuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const code = searchParams.get("code");
const next = searchParams.get("next");

const { mutate: login } = useLoginMutation();

useEffect(() => {
if (code) {
login(
{ code },
{
onSuccess: () => {
clearClientSessionCache();

const redirectUrl = next || "/";

router.replace(redirectUrl);
},
onError: error => {
console.error("로그인에 실패했습니다:", error);
alert("로그인에 실패했습니다. 다시 시도해주세요.");
router.replace("/login");
},
}
);
} else {
alert("비정상적인 접근입니다.");
router.replace("/");
}
}, [code, login, router, next]);

return <div>로그인 중입니다...</div>;
}
58 changes: 58 additions & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { type NextRequest, NextResponse } from "next/server";

import { postLogin } from "@/app/(auth)/_api/auth/auth.api";
import { TOKEN_TIMES } from "@/constants/time.constants";
import { type ApiError } from "@/lib/api";
import { UnauthorizedException } from "@/lib/exceptions";
import { getSessionFromServer } from "@/lib/session";

/**
* 로그인 요청
* @param req - 요청 객체
* @returns 응답 객체
*/
export const POST = async (req: NextRequest) => {
const { code } = await req.json();
const origin = req.headers.get("origin");

if (!code) {
return NextResponse.json<ApiError>(
{ errorMessage: "인가 코드가 필요합니다." },
{ status: 400 }
);
}

if (!origin) {
return NextResponse.json<ApiError>(
{ errorMessage: "올바르지 않은 요청입니다." },
{ status: 400 }
);
}

try {
const data = await postLogin({
code,
origin,
});
const session = await getSessionFromServer();

session.isLoggedIn = true;
session.accessToken = data.token.accessToken;
session.refreshToken = data.token.refreshToken;
session.userId = String(data.information.id);
// TODO: 백엔드로부터 토큰 만료 시간 받아오면 변경하기 (1시간)
session.accessTokenExpiresAt =
Date.now() + TOKEN_TIMES.ACCESS_TOKEN_LIFESPAN;

await session.save();

return NextResponse.json(data.information);
} catch (error) {
console.error("Login failed:", error);

return NextResponse.json<ApiError>(
{ errorMessage: "로그인에 실패했습니다." },
{ status: error instanceof UnauthorizedException ? 401 : 400 }
);
}
};
35 changes: 35 additions & 0 deletions src/app/api/auth/reissue/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";

import { postReissue } from "@/app/(auth)/_api/auth/auth.api";
import { type ApiError } from "@/lib/api";
import { getSessionFromServer } from "@/lib/session";

export const POST = async () => {
const session = await getSessionFromServer();
const { refreshToken } = session;

if (!refreshToken) {
return NextResponse.json<ApiError>(
{ errorMessage: "리프레시 토큰이 없습니다." },
{ status: 401 }
);
}

try {
const data = await postReissue({ refreshToken });

session.accessToken = data.accessToken;
session.refreshToken = data.refreshToken;
await session.save();
Comment thread
Seojunhwan marked this conversation as resolved.

return NextResponse.json(session);
} catch (error) {
console.error("Reissue failed:", error);
// 토큰 재발급 실패 시 세션 초기화
session.destroy();
Comment thread
Seojunhwan marked this conversation as resolved.
return NextResponse.json<ApiError>(
{ errorMessage: "토큰 재발급에 실패했습니다." },
{ status: 401 }
);
}
};
8 changes: 8 additions & 0 deletions src/app/api/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";

import { getSessionFromServer } from "@/lib/session";

export const GET = async () => {
const session = await getSessionFromServer();
return NextResponse.json(session);
};
Comment thread
Seojunhwan marked this conversation as resolved.
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./time.constants";
29 changes: 29 additions & 0 deletions src/constants/time.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 기본 시간 단위 (밀리초)
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

export const TIME = {
SECOND,
MINUTE,
HOUR,
DAY,
};

/**
* 인증 및 토큰과 관련된 시간 상수(밀리초 단위)를 정의합니다.
*/
export const TOKEN_TIMES = {
// /** Access Token의 유효 시간 (1시간) */
ACCESS_TOKEN_LIFESPAN: 1 * HOUR,

// /** 클라이언트 세션 캐시 유지 시간 (59분) */
CLIENT_SESSION_CACHE_DURATION: 59 * MINUTE,

// /** 주기적 토큰 재발급 간격 (59분) */
PERIODIC_TOKEN_REFRESH_INTERVAL: 59 * MINUTE,

// /** 미들웨어에서 토큰 만료 임박으로 간주하고 재발급을 시도하는 시간 (5분) */
MIDDLEWARE_TOKEN_REFRESH_THRESHOLD: 5 * MINUTE,
};
Loading