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
25 changes: 25 additions & 0 deletions src/app/api/_lib/tokenUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cookies } from 'next/headers';

export const setTokenCookies = async (
accessToken: string,
refreshToken: string
) => {
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production';

// 액세스 토큰 저장
cookieStore.set('accessToken', accessToken, {
httpOnly: true,
sameSite: isProduction ? 'none' : 'lax',
secure: isProduction,
maxAge: 60 * 30, // 30분
});

// 리프레시 토큰 저장
cookieStore.set('refreshToken', refreshToken, {
httpOnly: true,
sameSite: isProduction ? 'none' : 'lax',
secure: isProduction,
maxAge: 60 * 60 * 24 * 14, // 14일
});
};
26 changes: 26 additions & 0 deletions src/app/api/auth/kakao/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils';
import { KakaoLoginRequestBody } from '@/src/services/pages/login/api';
import { TokenUserResponseType } from '@/src/types/userType';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
const body: KakaoLoginRequestBody = await req.json();

const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/oauth/sign-in/kakao`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
const data: TokenUserResponseType = await res.json();

if (!res.ok) return NextResponse.json(data, { status: res.status });

const { accessToken, refreshToken } = data;

await setTokenCookies(accessToken, refreshToken);

return NextResponse.json(data);
}
23 changes: 23 additions & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils';
import { loginRequestBody } from '@/src/services/pages/login/api';
import { TokenUserResponseType } from '@/src/types/userType';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
const body: loginRequestBody = await req.json();

const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data: TokenUserResponseType = await res.json();

if (!res.ok) return NextResponse.json(data, { status: res.status });

const { accessToken, refreshToken } = data;

await setTokenCookies(accessToken, refreshToken);

return NextResponse.json(data);
}
10 changes: 10 additions & 0 deletions src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';

export async function DELETE() {
const res = NextResponse.json({ ok: true });

res.cookies.delete('accessToken');
res.cookies.delete('refreshToken');

return res;
}
136 changes: 136 additions & 0 deletions src/app/api/proxy/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { setTokenCookies } from '@/src/app/api/_lib/tokenUtils';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

interface TokenReissueResponse {
accessToken: string;
refreshToken: string;
}

interface CustomRequestInit extends RequestInit {
duplex?: 'half' | 'full' | string;
}

const handleApiResponse = async (res: Response) => {
// 204 코드시 처리
if (res.status === 204) {
return new NextResponse(null, { status: 204 });
}

const contentType = res.headers.get('content-type') || '';

if (contentType.includes('application/json')) {
// json 처리
const resData = await res.json();
return NextResponse.json(resData, { status: res.status });
} else {
// 바이너리/파일 처리
const buffer = await res.arrayBuffer();
return new NextResponse(buffer, { status: res.status });
}
};

const fetchWithAccessToken = async (req: NextRequest) => {
const cookieStore = await cookies();
const headers = new Headers(req.headers);
const accessToken = cookieStore.get('accessToken')?.value;
const { pathname, search } = req.nextUrl;
const targetPath = pathname.replace('/api/proxy', '') + search;

// 액세스 토큰 주입
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}

const fetchOptions: CustomRequestInit = {
method: req.method,
headers,
body: req.body,
duplex: 'half',
};

// request 요청
return await fetch(
`${process.env.NEXT_PUBLIC_API_URL}${targetPath}`,
fetchOptions
);
};

const PUBLIC_PATH_PATTERNS = [
/^\/$/,
/^\/detail\/\d+$/,
/^\/login$/,
/^\/login\/social\/kakao$/,
/^\/signup$/,
/^\/signup\/social\/kakao$/,
];

const handleProxyRequest = async (req: NextRequest): Promise<NextResponse> => {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refreshToken')?.value;

const res = await fetchWithAccessToken(req);

// 액세스 토큰 만료 + 리프레시 토큰이 있다면,
if (res.status === 401 && refreshToken) {
const refreshHeaders = new Headers();
refreshHeaders.set('Authorization', `Bearer ${refreshToken}`);

const refreshTokenRes = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/auth/tokens`,
{
method: 'POST',
headers: refreshHeaders,
}
);
const data: TokenReissueResponse = await refreshTokenRes.json();

// 갱신 실패시
if (!refreshTokenRes.ok) {
// 로그인 페이지로 리다이렉트
const response = new NextResponse(null);

// 쿠키 삭제
response.cookies.delete('accessToken');
response.cookies.delete('refreshToken');

const currentPath = req.nextUrl.pathname;
const isPublicPath = PUBLIC_PATH_PATTERNS.some((regex) =>
regex.test(currentPath)
);

if (!isPublicPath) {
// 인증 필요 -> 로그인 페이지로 redirect
return NextResponse.redirect(`/login?redirect_path=${currentPath}`);
}

// 인증 필요 없는 Public Path -> 그냥 상태만 초기화
return response;
}

const { accessToken: newAccessToken, refreshToken: newRefreshToken } = data;

// 새롭게 발급 받은 토큰 재설정
await setTokenCookies(newAccessToken, newRefreshToken);

// 다시 api 요청
const retryRes = await fetchWithAccessToken(req);

return handleApiResponse(retryRes);
}

return handleApiResponse(res);
};

export async function GET(req: NextRequest) {
return handleProxyRequest(req);
}
export async function POST(req: NextRequest) {
return handleProxyRequest(req);
}
export async function PATCH(req: NextRequest) {
return handleProxyRequest(req);
}
export async function DELETE(req: NextRequest) {
return handleProxyRequest(req);
}
36 changes: 33 additions & 3 deletions src/components/pages/login/KakaoLoginClient.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
'use client';
import LoadingSpinner from '@/src/components/primitives/LoadingSpinner';
import { KAKAO_REDIRECT_URI_LOGIN } from '@/src/constants/social';
import useKakaoLoginUser from '@/src/hooks/pages/auth/useKakaoLoginUser';
import {
KakaoLoginRequestBody,
kakaoLoginUser,
} from '@/src/services/pages/login/api';
import { queries } from '@/src/services/primitives/queries';
import { useToastStore } from '@/src/store/useToastStore';
import { TokenUserResponseType } from '@/src/types/userType';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';

export default function KakaoLoginClient() {
const router = useRouter();
const queryClient = useQueryClient();
const flagRef = useRef(false);
const searchParams = useSearchParams();
const code = searchParams.get('code');
const kakaoLoginMutation = useKakaoLoginUser();
const redirectPath = searchParams.get('state');
const createToast = useToastStore((state) => state.createToast);
const kakaoLoginMutation = useMutation({
mutationFn: async (data: KakaoLoginRequestBody) =>
await kakaoLoginUser(data),
onSuccess: async (data: TokenUserResponseType) => {
queryClient.setQueryData(queries.user(), data.user); // 리액트 쿼리 데이터 캐싱

// redirectPath 값이 있으면, 해당 페이지로 다시 이동
// 보안 취약점을 강화하기 위해 redirectPath.startsWith('/')로 현재 도메인내의 경로인지 확인
if (redirectPath && redirectPath.startsWith('/')) {
router.replace(redirectPath);
} else {
router.replace('/');
}
},
onError: (error) => {
console.error(error);
createToast({
message: '로그인에 실패하였습니다. 다시 시도 해주세요.',
type: 'failed',
});
router.replace('/login');
},
});

useEffect(() => {
if (!code) {
Expand All @@ -32,7 +62,7 @@ export default function KakaoLoginClient() {
redirectUri: KAKAO_REDIRECT_URI_LOGIN,
token: code,
});
}, [code, kakaoLoginMutation, router]);
}, [code, kakaoLoginMutation, router, createToast]);

return (
<div className='flex flex-col items-center justify-center gap-4'>
Expand Down
45 changes: 32 additions & 13 deletions src/components/pages/login/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import Button from '@/src/components/primitives/Button';
import FormInput from '@/src/components/primitives/input/FormInput';
import AlertModal from '@/src/components/primitives/modal/AlertModal';
import useLoginUser from '@/src/hooks/pages/auth/useLoginUser';
import { loginRequestBody, loginUser } from '@/src/services/pages/login/api';
import { queries } from '@/src/services/primitives/queries';
import { TokenUserResponseType } from '@/src/types/userType';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';

Expand All @@ -16,24 +20,39 @@ interface FormDataType {
const EMAIL_REGEXP = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

export default function LoginForm() {
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const redirectPath = searchParams.get('redirect_path');
const [alertOpen, setAlertOpen] = useState(false);
const loginMutation = useLoginUser();
const loginMutation = useMutation({
mutationFn: (data: loginRequestBody) => loginUser(data),
onSuccess: (data: TokenUserResponseType) => {
queryClient.setQueryData(queries.user(), data.user); // 리액트 쿼리 데이터 캐싱

// redirectPath 값이 있으면, 해당 페이지로 다시 이동
// 보안 취약점을 강화하기 위해 redirectPath.startsWith('/')로 현재 도메인내의 경로인지 확인
if (redirectPath && redirectPath.startsWith('/')) {
router.replace(redirectPath);
} else {
router.replace('/');
}
},
onError: (error) => {
const err = error as AxiosError;
if (err?.response?.status === 400 || err?.response?.status === 404) {
setAlertOpen(true);
}
},
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<FormDataType>({ mode: 'onChange' });

const handleSubmitForm = async (formData: FormDataType) => {
loginMutation.mutate(formData, {
onError: (error) => {
const err = error as AxiosError;
if (err?.response?.status === 400 || err?.response?.status === 404) {
setAlertOpen(true);
}
},
});
};
const handleSubmitForm = (formData: FormDataType) =>
loginMutation.mutateAsync(formData);

return (
<>
Expand Down Expand Up @@ -78,7 +97,7 @@ export default function LoginForm() {
className='mt-2 md:mt-[10px]'
disabled={!isValid || isSubmitting}
>
로그인하기
{isSubmitting ? '로그인중...' : '로그인하기'}
</Button>
</form>
<AlertModal
Expand Down
4 changes: 1 addition & 3 deletions src/components/pages/sidebar/ProfilePicture.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';

import { queries } from '@/src/services/primitives/queries';
import { useTokenStore } from '@/src/store/useTokenStore';
import { useQuery } from '@tanstack/react-query';
import Image from 'next/image';
import EditIcon from '@/public/images/icons/EditIcon.svg';
Expand All @@ -10,8 +9,7 @@ import { patchMyInfo, postAvatar } from '@/src/services/pages/users/api';
import { getQueryClient } from '@/src/utils/getQueryClient';

export default function ProfilePicture() {
const { accessToken } = useTokenStore();
const { data: userData } = useQuery(queries.userOptions(accessToken));
const { data: userData } = useQuery(queries.userOptions());
const queryClient = getQueryClient();
const fileRef = useRef<HTMLInputElement>(null);

Expand Down
Loading