저번에 Nest.js에서는 백엔드를 구현했으니 이번엔 클라이언트에서 처리를 어떻게 해야할지에 대해서 알아봅시다. 이것도 제가 그냥 뭐뭐가 필요할 거 같다라고 해서 정리한 거라 참고만 바랍니다.
https://mondaymonday2.tistory.com/996
최상위 layout.tsx
"use client";
import { Noto_Sans } from "next/font/google";
import "./globals.css";
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/toaster";
import { store } from "@/redux/store";
import { Provider } from "react-redux";
import { CookiesProvider } from "react-cookie";
import { LoginCheckWrapper } from "@/app/(auth)/login-check";
const notoSans = Noto_Sans({
subsets: ["latin"], // 필요에 따라 다른 서브셋 추가
weight: ["400", "700"], // 필요에 따라 다른 굵기 추가
});
const queryClient = new QueryClient();
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<html lang="en">
<body className={notoSans.className}>
<CookiesProvider>
<LoginCheckWrapper>{children}</LoginCheckWrapper>
<Toaster />
<ReactQueryDevtools initialIsOpen={true} />
</CookiesProvider>
</body>
</html>
</QueryClientProvider>
</Provider>
);
}
LoginCheckWrapper라는 Component를 만들어서 인증에 대한 처리를 하게 했습니다.
page.tsx (로그인 페이지)
"use client";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import loginImg from "../../public/images/login.jpg";
import googleLogo from "../../public/images/google.png";
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { KeyboardEventHandler, useEffect, useState } from "react";
import { toast } from "@/components/ui/use-toast";
import { fetcher } from "@/common/fetcher";
import { useCookies } from "react-cookie";
interface IUser {
username: string;
password: string;
}
/** ─── Handler ─── **/
async function signIn({ username, password }: IUser) {
return await fetcher.post(
process.env.NEXT_PUBLIC_API_URL + "/signin",
{
username: username,
password: password,
},
{ withCredentials: true },
);
}
export default function Home() {
/** ─── Hooks ─── **/
const router = useRouter();
/** ─── State ─── **/
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [cookies, setCookie] = useCookies();
/** ─── Fetch ─── **/
const { mutate: signInMutate } = useMutation({
mutationKey: ["signin"],
mutationFn: signIn,
onSuccess: (data) => {
toast({
title: "login!!",
});
router.push("/main");
},
// ToDo Error Handle 정형화
onError: (e: AxiosError<any>) => {
toast({
title: e.response!.data.message,
});
},
});
/** ─── 이벤트 핸들러 ─── **/
const checkRememberMe = (isChecked: boolean) => {
setCookie("REMEMBER", isChecked, { expires: new Date(9999, 12, 31) });
setRememberMe(isChecked); // React Cookie는 State 관리가 아니라서 따로 필요
};
const login = () => {
signInMutate({ username: username, password: password });
setCookie("LOGIN", true);
};
const onSubmitSearch = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
login();
}
};
const googleLogin = () => {
router.replace(process.env.NEXT_PUBLIC_API_URL + "/auth/google");
setCookie("LOGIN", true);
};
/** ─── useEffect ─── **/
useEffect(() => {
setRememberMe(cookies.REMEMBER);
}, [cookies.REMEMBER]);
useEffect(() => {
setCookie("LOGIN", false);
}, []);
/** ─── TSX ─── **/
return (
<main>
<div className="flex m-7">
<div className="basis-1/6">
<div className="flex justify-between">
<div className="flex-grow text-xl font-semibold text-brand w-48">
PRISM WEAVER
</div>
</div>
</div>
<div className="basis-1/4">
<div className="mx-10 mt-40 ">
<div className="font-semibold text-4xl">Sign In</div>
<div className="grid w-full items-center gap-1.5 mt-7">
<Label htmlFor="username">Username</Label>
<Input
type="text"
id="username"
placeholder="Username"
onChange={(event) => {
setUsername(event.target.value);
}}
/>
</div>
<div className="grid w-full items-center gap-1.5 mt-7">
<Label htmlFor="password">Password</Label>
<Input
type="password"
id="password"
placeholder="Password"
onChange={(event) => {
setPassword(event.target.value);
}}
onKeyDown={(event) => onSubmitSearch(event)}
/>
</div>
<div className="flex mt-2 items-end">
<Switch
className="mt-2"
onCheckedChange={(checked) => {
checkRememberMe(checked);
}}
checked={rememberMe}
/>
<span className="mx-2 min-w-40">Remember Me</span>
</div>
<Button
className="mt-12 text-base w-full bg-brand-disabled"
onClick={() => {
login();
}}
>
Login
</Button>
<div>
<Button
className="mt-5 text-base w-full bg-brand-disabled"
onClick={() => {
googleLogin();
}}
>
<Image
className="mx-2"
src={googleLogo}
alt={"google logo"}
width={20}
height={20}
/>
Sign in with Google
</Button>
</div>
<div className="flex mt-4 justify-center">
<div className="mx-3">
<span className="min-w-20 text-gray-400 text-sm">
forgot ID
</span>
</div>
<Separator orientation="vertical" className="py-3" />
<div className="mx-3" onClick={() => router.push("forgot")}>
<span className="min-w-20 text-gray-400 text-sm hover:cursor-pointer">
forgot PW
</span>
</div>
<Separator orientation="vertical" className="py-3" />
<div className="mx-3" onClick={() => router.push("signup")}>
<span className="min-w-20 text-gray-400 text-sm hover:cursor-pointer">
Sign Up
</span>
</div>
</div>
</div>
</div>
<div className="basis-3/6">
<Image
className="object-fill mt-32 rounded-2xl min-w-fit"
src={loginImg}
alt="login"
/>
</div>
</div>
</main>
);
}
자동로그인을 위해 REMEMBER를 Cookie에 담아서 기록하게 했습니다.
로그인했을 때는 LOGIN 여부를 Cookie에 담아서 기록하게 했습니다.
이 두개를 사용한 이유는 밑에 나오게 됩니다.
fethcer.tsx
import axios from "axios";
import { store } from "@/redux/store";
import { authSlice } from "@/redux/auth/auth-slice";
import { ROLE } from "@/common/enum";
// Axios 인스턴스 생성
export const fetcher = axios.create({ withCredentials: true });
// Request 인터셉터 추가
fetcher.interceptors.request.use(
(config) => {
const token = store.getState().auth.accessToken;
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response 인터셉터 추가
fetcher.interceptors.response.use((response) => {
const accessToken = response.headers["access-token"];
const isRefreshTokenInvalid = response.headers["refresh-token-invalid"];
const role = response.headers["role"];
// store.dispatch(authSlice.actions.setIsRefreshTokenValid(false));
/** Refresh Token 만료시 핸들링 **/
if (isRefreshTokenInvalid) {
store.dispatch(authSlice.actions.setAccessToken(""));
// store.dispatch(authSlice.actions.setIsRefreshTokenValid(true));
store.dispatch(authSlice.actions.setRole(ROLE.VISITOR));
}
/** Access Token 만료시 핸들링 **/
if (accessToken) {
store.dispatch(authSlice.actions.setAccessToken(accessToken));
store.dispatch(authSlice.actions.setRole(role));
}
// 응답 데이터 반환
return response.data;
});
// fetch custom
// https://stackoverflow.com/questions/44820568/set-default-header-for-every-fetch-request
api 통신에 필요한 axios를 사용했으며 백엔드에서 access token과 refresh token 검증을 위해 front에서 통신할 때마다 보내야하는데 모든 axios에 하나하나씩 달면 유지보수도 힘들기 때문에 Request를 보낼때(모든 api통신) 인터셉터를 통해 Redux(메모리)에 저장한 access token을 Header값에 붙여서 보냅니다.
서버로부터 받은 응답의 경우도 header값에 access token과 refresh token 유효여부, 인가에 따른 페이지 접근권한을 위한 role정보를 받아서 refresh token이 유효하지 않는 경우 메모리에 있는 access token을 초기화시킵니다.
access token이 유효한 경우는 access token을 메모리에 저장합니다. 메모리에 저장하는 이유는 탈취에 위험이 전혀 없기 때문입니다. [CSRF, XSS 방지] (쿠키에 httpOnly와 samesite 처리하는 것도 또다른 방법이라고도 합니다)
login-check.tsx (jwt 검증)
"use client";
import { store } from "@/redux/store";
import React, { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { EXCEPT_ROUTE } from "@/common/constants";
import { fetcher } from "@/common/fetcher";
import { useQuery } from "@tanstack/react-query";
import { useCookies } from "react-cookie";
const getAccessToken = async (): Promise<any> => {
return await fetcher.post<any>(
process.env.NEXT_PUBLIC_API_URL + "/getAccessToken",
);
};
export const LoginCheckWrapper = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const router = useRouter();
const pathname = usePathname();
const [cookies, setCookie] = useCookies();
const [isTokenChecked, setIsTokenChecked] = useState(false); // 액세스 토큰 재발급 요청이 끝났다는 표시
const [redirectStep, setRedirectStep] = useState(""); // 리다이렉트 문제 해결을 위해 넣은 단계 (토큰 없이 /main 이동시 / 리다이렉트할 때 /main 페이지 내용이 잠깐 보이는 현상 방지)
/** 액세스 토큰 재발급 요청 **/
const { isFetching: isAccessTokenFetching } = useQuery({
queryKey: ["getAccessToken"],
queryFn: getAccessToken,
});
/** 특정 페이지로 바로 접근하는 경우 (새로고침, 직접 주소 입력) **/
useEffect(() => {
/* 엑세스 토큰 발급 요청 했을 때 **/
if (!isAccessTokenFetching) {
const token = store.getState().auth.accessToken;
const isRememberMe = cookies.REMEMBER;
const isLogin = cookies.LOGIN;
/* 리프레시 토큰이 만료 됐을 때 로그아웃 (엑세스 토큰 비정상 발급 + 토큰이 필요한 서버 주소인 경우) **/
if (token === "" && !EXCEPT_ROUTE.includes(pathname)) {
router.replace("/");
setRedirectStep("replace");
}
// 자동로그인 O, 엑세스토큰 정상발급(정상 리프레시토큰), 로그인페이지로 접근시 로그인 되었기 때문에 main 페이지로 이동
else if (isRememberMe === true && token !== "" && pathname === "/") {
router.replace("/main");
setCookie("LOGIN", true);
setRedirectStep("replace");
}
// 로그아웃 안 하고 나갔을 때 (토큰 값은 있어도 Login 상태가 아닌 경우)
else if (token !== "" && !EXCEPT_ROUTE.includes(pathname) && !isLogin) {
router.replace("/");
setRedirectStep("replace");
}
// 로그인을 한 상태의 경우 메인페이지 접속시 리다이렉트
else if (isLogin && pathname === "/" && token !== "") {
router.replace("/main");
setRedirectStep("replace");
}
setIsTokenChecked(true);
}
if (isTokenChecked && redirectStep === "replace") {
setRedirectStep("done");
}
}, [pathname, isAccessTokenFetching]);
/** 토큰 유효 체크가 안 됐을 때 **/
if (!isTokenChecked) {
return <></>;
}
/** 리다이렉트시 처리 **/
if (isTokenChecked && redirectStep === "done") {
return <>{children}</>;
}
/** 일반 페이지 접근 (리다이렉트 아닐 때) **/
if (isTokenChecked && redirectStep === "") {
return <>{children}</>;
}
};
경우의 수에 따라 처리 방법을 분기 시켜놨다.
- 리프레시 토큰이 만료시 처리
- access token 요청처리하면서 fetcher를 통해 refresh token이 유효하지 않으면 access token에 값이 없기 때문에 분기 처리하고 그 이후에 로그인 페이지로 라우팅시킨다.
- 자동 로그인했을 때 처리 (자동로그인 설정, 리프레시 토큰 정상일 때 로그인페이지로 이동시)
- 메인페이지로 이동
- 로그아웃 안 하고 브라우저 끌 때 (자동로그인 미설정, 리프레시 토큰 정상일 때)
- 자동로그인 설정 안 해도 다시 브라우저 다시 키면 로그인한 상태인데 로그인할 때 쿠키에 login 했다는 표시를 세션단위로 남겨서 브라우저가 닫히면 사라지게 한다. 그 이후 로그인 페이지로 리다이렉트
- 로그인 한 상태로 로그인 페이지 접속
- 로그인을 한 상태이기 때문에 로그인페이지로 가면 메인페이지로 보내버린다
비동기로 처리하기 때문에 토큰 유효 체크가 안 됐을 땐 아무런 화면이 보이면 안 된다. 또한 리다이렉트가 완전이 끝나야 페이지가 보여야하는데 setRedirect로 redirect가 실행되었고 끝났다는 걸 따로 체크 안 하면 리다이렉트 하기 전 페이지가 잠깐 보이게 된다. (/main 접속시 0.5초정도 /main페이지 보였다가 /로 리다이렉트) 이걸 방지하기 위해서 따로 처리를 분리했다. 리다이렉트가 아닌 경우에도 처리를 해야하기 때문에 마지막 처럼 처리했다.
F5를 누르면 전체가 다시 렌더링 되는데 이 때 메모리에 있는 값도 날라가기 때문에 새로고침하면 위에 컴포넌트가 다시 렌더링 되면서 access token값을 다시 요청하게 된다.
'[Next.js]' 카테고리의 다른 글
[Next.js] 나만의 환경 구축 (라이브러리 설치, IDE 설정, 네이밍 컨벤션, 코드 스타일) (0) | 2024.08.21 |
---|