Skip to content

Commit ffffa81

Browse files
authored
Merge pull request #61 from YAPP-Github/feature/PRODUCT-97
2 parents 9565453 + 4269b15 commit ffffa81

File tree

26 files changed

+901
-55
lines changed

26 files changed

+901
-55
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"@tanstack/react-query-devtools": "^5.77.0",
2222
"@vanilla-extract/css": "^1.17.2",
2323
"@vanilla-extract/recipes": "^0.5.7",
24+
"iron-session": "^8.0.4",
25+
"ky": "^1.8.1",
2426
"next": "15.3.2",
2527
"react": "^19.0.0",
2628
"react-dom": "^19.0.0",

pnpm-lock.yaml

Lines changed: 135 additions & 55 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { http, nextHttp } from "@/lib/api/client";
2+
3+
import type {
4+
LoginRequest,
5+
LoginResponse,
6+
ReissueRequest,
7+
ReissueResponse,
8+
} from "./auth.types";
9+
10+
/**
11+
* 백엔드의 /api/auth/login 엔드포인트에 로그인 요청을 보냅니다.
12+
*
13+
* @param {LoginRequest} params - 카카오 인가 코드
14+
* @returns {Promise<LoginResponse>} 로그인 응답 데이터
15+
*/
16+
export const postLogin = async (params: LoginRequest) => {
17+
return await http
18+
.post("api/auth/login", { json: params })
19+
.json<LoginResponse>();
20+
};
21+
22+
/**
23+
* 백엔드의 /api/auth/reissue 엔드포인트에 토큰 재발급 요청을 보냅니다.
24+
*
25+
* @param {ReissueRequest} params - 리프레시 토큰
26+
* @returns {Promise<ReissueResponse>} 재발급된 토큰 데이터
27+
*/
28+
export const postReissue = async (params: ReissueRequest) => {
29+
return await http
30+
.post("api/auth/reissue", { json: params })
31+
.json<ReissueResponse>();
32+
};
33+
34+
/**
35+
* OAuth 제공자의 인증 페이지로 브라우저를 리다이렉트시킵니다.
36+
*
37+
* @description
38+
* 이 함수를 호출하면, 서버로부터 302 리다이렉트 응답을 받아
39+
* OAuth 제공자의 인증 페이지로 즉시 이동합니다.
40+
* 반환 값은 없으며, 호출 즉시 페이지가 전환됩니다.
41+
*/
42+
export const redirectToKakaoOAuthLoginPage = async () => {
43+
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/login/oauth`;
44+
};
45+
46+
type Information = Omit<LoginResponse, "token">["information"];
47+
48+
/**
49+
* Next.js API Route(/api/auth/login)를 통해 로그인 요청을 보냅니다.
50+
*
51+
* @param {LoginRequest} params - 카카오 인가 코드
52+
* @returns {Promise<Information>} 회원 정보
53+
*/
54+
export const postClientLogin = async (params: Omit<LoginRequest, "origin">) => {
55+
return await nextHttp
56+
.post("api/auth/login", { json: params })
57+
.json<Information>();
58+
};
59+
60+
/**
61+
* Next.js API Route(/api/auth/reissue)를 통해 토큰 재발급 요청을 보냅니다.
62+
*
63+
* @returns {Promise<ReissueResponse>} 재발급된 세션 정보
64+
*/
65+
export const postClientReissue = async () => {
66+
return await nextHttp.post("api/auth/reissue").json<ReissueResponse>();
67+
};
68+
69+
/**
70+
* Next.js API Route(/api/auth/logout)를 통해 세션 삭제(로그아웃) 요청을 보냅니다.
71+
*
72+
* @returns {Promise<void>} 로그아웃 결과
73+
*/
74+
export const deleteClientSession = async () => {
75+
return await nextHttp.delete("api/auth/logout").json();
76+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useMutation } from "@tanstack/react-query";
2+
3+
import {
4+
deleteClientSession,
5+
postClientLogin,
6+
postClientReissue,
7+
} from "./auth.api";
8+
9+
export const useLoginMutation = () => {
10+
return useMutation({
11+
mutationFn: postClientLogin,
12+
});
13+
};
14+
15+
export const useReissueMutation = () => {
16+
return useMutation({
17+
mutationFn: postClientReissue,
18+
});
19+
};
20+
21+
export const useDeleteSessionMutation = () => {
22+
return useMutation({
23+
mutationFn: deleteClientSession,
24+
});
25+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export type LoginRequest = {
2+
code: string;
3+
origin: string;
4+
};
5+
6+
export type LoginResponse = {
7+
token: {
8+
accessToken: string;
9+
refreshToken: string;
10+
};
11+
information: {
12+
id: number;
13+
isSignUp: boolean;
14+
};
15+
};
16+
17+
export type ReissueRequest = {
18+
refreshToken: string;
19+
};
20+
21+
export type ReissueResponse = {
22+
accessToken: string;
23+
refreshToken: string;
24+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { nextHttp } from "@/lib/api/client";
2+
3+
import type { SessionData } from "./session.types";
4+
5+
/**
6+
* Next.js API Route(/api/session)를 통해 세션 정보를 요청합니다.
7+
*
8+
* @returns {Promise<SessionData>} 세션 정보
9+
*/
10+
export const getSession = async () => {
11+
return await nextHttp.get<SessionData>("api/session").json();
12+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { queryOptions } from "@tanstack/react-query";
2+
3+
import { getSession } from "./session.api";
4+
5+
const sessionQueryKeys = {
6+
session: ["session"],
7+
};
8+
9+
export const sessionQueries = {
10+
session: queryOptions({
11+
queryKey: sessionQueryKeys.session,
12+
queryFn: getSession,
13+
}),
14+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { type SessionData } from "@/lib/session";
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import { useRouter, useSearchParams } from "next/navigation";
4+
import { useEffect } from "react";
5+
6+
import { useLoginMutation } from "@/app/(auth)/_api/auth/auth.queries";
7+
import { clearClientSessionCache } from "@/lib/session";
8+
9+
export default function AuthCallbackPage() {
10+
const router = useRouter();
11+
const searchParams = useSearchParams();
12+
const code = searchParams.get("code");
13+
const next = searchParams.get("next");
14+
15+
const { mutate: login } = useLoginMutation();
16+
17+
useEffect(() => {
18+
if (code) {
19+
login(
20+
{ code },
21+
{
22+
onSuccess: () => {
23+
clearClientSessionCache();
24+
25+
const redirectUrl = next || "/";
26+
27+
router.replace(redirectUrl);
28+
},
29+
onError: error => {
30+
console.error("로그인에 실패했습니다:", error);
31+
alert("로그인에 실패했습니다. 다시 시도해주세요.");
32+
router.replace("/login");
33+
},
34+
}
35+
);
36+
} else {
37+
alert("비정상적인 접근입니다.");
38+
router.replace("/");
39+
}
40+
}, [code, login, router, next]);
41+
42+
return <div>로그인 중입니다...</div>;
43+
}

src/app/api/auth/login/route.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type NextRequest, NextResponse } from "next/server";
2+
3+
import { postLogin } from "@/app/(auth)/_api/auth/auth.api";
4+
import { TOKEN_TIMES } from "@/constants/time.constants";
5+
import { type ApiError } from "@/lib/api";
6+
import { UnauthorizedException } from "@/lib/exceptions";
7+
import { getSessionFromServer } from "@/lib/session";
8+
9+
/**
10+
* 로그인 요청
11+
* @param req - 요청 객체
12+
* @returns 응답 객체
13+
*/
14+
export const POST = async (req: NextRequest) => {
15+
const { code } = await req.json();
16+
const origin = req.headers.get("origin");
17+
18+
if (!code) {
19+
return NextResponse.json<ApiError>(
20+
{ errorMessage: "인가 코드가 필요합니다." },
21+
{ status: 400 }
22+
);
23+
}
24+
25+
if (!origin) {
26+
return NextResponse.json<ApiError>(
27+
{ errorMessage: "올바르지 않은 요청입니다." },
28+
{ status: 400 }
29+
);
30+
}
31+
32+
try {
33+
const data = await postLogin({
34+
code,
35+
origin,
36+
});
37+
const session = await getSessionFromServer();
38+
39+
session.isLoggedIn = true;
40+
session.accessToken = data.token.accessToken;
41+
session.refreshToken = data.token.refreshToken;
42+
session.userId = String(data.information.id);
43+
// TODO: 백엔드로부터 토큰 만료 시간 받아오면 변경하기 (1시간)
44+
session.accessTokenExpiresAt =
45+
Date.now() + TOKEN_TIMES.ACCESS_TOKEN_LIFESPAN;
46+
47+
await session.save();
48+
49+
return NextResponse.json(data.information);
50+
} catch (error) {
51+
console.error("Login failed:", error);
52+
53+
return NextResponse.json<ApiError>(
54+
{ errorMessage: "로그인에 실패했습니다." },
55+
{ status: error instanceof UnauthorizedException ? 401 : 400 }
56+
);
57+
}
58+
};

0 commit comments

Comments
 (0)