본문 바로가기
코딩 정보/React

[React] 로그인 시 토큰 기반 인증 만들어보기

by 꽁이꽁설꽁돌 2024. 8. 24.
728x90
반응형

목차

     

    토큰 기반 방식의 로그인 장점

    서버가 아닌 클라이언트에서 유저의 인증정보를 관리한다는 점이다.

    1. 서버에서 유저의 상태를 가지고 있지 않아 서버는 무상태적인 아키텍쳐를 구축할 수 있다.
    2. 하나의 토큰으로 다수의 서버에 인증가능하다는 점에서 확장성을 갖는다.
    3. 토큰 생성만을 담당하는 인증용 서버를 만들 수 있는 등 어디서나 토큰의 생성이 가능하다.

     

    토큰 기반 인증 방식

    1. 유저가 인증정보를 담아 서버에 로그인 요청을 보낸다.
    2. 서버는 데이터베이스에 저장된 유저의 인증정보를 확인한다.
    3. 인증에 성공했다면 서버는 유저에 대한 권한정보를 서버의 비밀키와 함께 토큰을 생성한다.
    4. 서버는 Authorization 헤더에 토큰을 담아 클라이언트에 전달한다.
    5. 클라이언트는 전달받은 토큰을 브라우저의 세션 스토리지 또는 로컬스토리지에 저장한다.
    6. 클라이언트가 서버로 리소스를 요청할 때 Authorization 헤더를 통해 토큰이 함께 전달된다.
    7. 서버는 전달받은 토큰을 서버의 비밀키로 검증한다. 이를 통해 토큰이 위조되었는지 토큰의 유효기간이 지나지 않았는지 확인한다.
    8. 토큰이 유효하다면 유저의 요청에 대한 응답 테이터를 전송한다.

     

     

    url쿼리에 따른 화면 전환

    아래와 같이 url쿼리에 따라 화면이 바뀌어야 하는 경우가 있는데 이럴 때 useSearchParams를 이용하면 된다.

     

     

     

     AuthForm.jsx

    import { Form, Link, useSearchParams } from "react-router-dom";
    
    import classes from "./AuthForm.module.css";
    
    function AuthForm() {
      const [searchParams] = useSearchParams();
      //login mode -> true
      const isLogin = searchParams.get("mode") === "login";
      return (
        <>
          <Form method="post" className={classes.form}>
            <h1>{isLogin ? "Log in" : "Create a new user"}</h1>
            <p>
              <label htmlFor="email">Email</label>
              <input id="email" type="email" name="email" required />
            </p>
            <p>
              <label htmlFor="image">Password</label>
              <input id="password" type="password" name="password" required />
            </p>
            <div className={classes.actions}>
            // 토글과 비슷한 기능을 함
              <Link to={`?mode=${isLogin ? "signup" : "login"}`}>
                {isLogin ? "Create new user" : "Login"}
              </Link>
              <button>Save</button>
            </div>
          </Form>
        </>
      );
    }
    
    export default AuthForm;

     

    사용자가 선택한 모드에 따른 요청보내기

    export async function action({ request }) {
      //URL 객체는 JavaScript에서
      //URL(Uniform Resource Locator)을 다루기 위한 인터페이스입니다.
      const searchParams = new URL(request.url).searchParams;
      //mode라는 특정 params를 가져옵니다. 기본값으로는 login으로 설정해 줍니다.
      const mode = searchParams.get("mode") || "login";
    
    	//특정 모드가 아니라면 오류를 발생합니다
      if (mode !== "login" && mode !== "signup") {
        throw json({ message: "Unsupported mode." }, { status: 402 });
      }
      
      const data = await request.formData();
      const fd = Object.fromEntries(data.entries());
    
      const response = await fetch("http://localhost:8080/" + mode, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(fd),
      });
    
      if (response.status === 422 || response.status === 401) {
        return response;
      }
      if (!response.ok) {
        throw json({ message: "could not authenticate user." }, { status: 500 });
      }
    
      return redirect("/");
    }

     

    로그인한 후 인증 토큰 저장하기

    export async function action({ request }) {
      //URL 객체는 JavaScript에서
      //URL(Uniform Resource Locator)을 다루기 위한 인터페이스입니다.
      const searchParams = new URL(request.url).searchParams;
      const mode = searchParams.get("mode") || "login";
    
      if (mode !== "login" && mode !== "signup") {
        throw json({ message: "Unsupported mode." }, { status: 402 });
      }
      const data = await request.formData();
      const fd = Object.fromEntries(data.entries());
    
      const response = await fetch("http://localhost:8080/" + mode, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(fd),
      });
    
      if (response.status === 422 || response.status === 401) {
        return response;
      }
      if (!response.ok) {
        throw json({ message: "could not authenticate user." }, { status: 500 });
      }
    
      const resData = await response.json();
      const token = resData.token;
    
    //이런식으로 자체 제공해주는 localStorage에 저장해 둔다.
      localStorage.setItem('token', token);
      return redirect("/");
    }

     

    아래와 같이 토큰을 활용하는 함수를 만들어 놓고 header요청 필요에 따라 써주면 된다.

    export function getAuthToken() {
      const token = localStorage.getItem("token");
      return token;
    }

     

    아래와 같이 잘 저장되는 것을 볼 수 있다.

     

    로그아웃 시 인증토큰 삭제하기

    import { redirect } from "react-router-dom";
    export function action(){
        localStorage.removeItem('token');
        return redirect('/');
    }

     

    아래와 같이 라우트 경로에 추가한 뒤 action함수를 넣어준다.

      {
            path: 'logout',
            action: logoutAction,
          }

     

    Form을 통해 해당링크가 불러지면 action함수가 작동해 삭제하게끔 만들었다.

    import { Form, NavLink } from "react-router-dom";
    
    import classes from "./MainNavigation.module.css";
    import NewsletterSignup from "./NewsletterSignup";
    
    function MainNavigation() {
      return (
        <header className={classes.header}>
          <nav>
            <ul className={classes.list}>
             ...
              <li>
                <Form method="post" action="/logout">
                  <button>Logout</button>
                </Form>
              </li>
            </ul>
          </nav>
          <NewsletterSignup />
        </header>
      );
    }
    
    export default MainNavigation;

     

    아래와 같이 잘 삭제되는 것을 볼 수 있다.

     

    로그인 유무에 따라 조건부 컴포넌트 랜더링하기

    아래와 같이 로그인 유무에 따라 수정 버튼, 삭제 버튼, 로그인 버튼 등이 바뀌어야 하는데

    이럴때 loader를 응용해 주면 된다.

     

    먼저 토큰을 불러와 주는 loader를 만들어 준다.

    export function tokenLoader() {
      return getAuthToken();
    }

     

    그 후 아래와 같이 최상단 컴포넌트에 id를 만들고 loader를 통해 token을 불러와 준다.

    그러면 토큰의 여부에 따라 알아서 자식 컴포넌트들이 재평가 된다.

     

    Main.jsx

    import { RouterProvider, createBrowserRouter } from "react-router-dom";
    
    ...
    
    const router = createBrowserRouter([
      {
        path: "/",
        element: <RootLayout />,
        errorElement: <ErrorPage />,
        id: "root",
        loader: checkAuthLoader,
        children: [
        ...
        ],
      },
    ]);
    
    function Certification() {
      return <RouterProvider router={router} />;
    }
    
    export default Certification;

     

    useRouteLoaderData를 통해 token을 받아오고 그 토큰에 따라 조건부 랜더링 해준다.

     

    MainNavigation.jsx

    import { Form, NavLink, useRouteLoaderData } from "react-router-dom";
    
    import classes from "./MainNavigation.module.css";
    import NewsletterSignup from "./NewsletterSignup";
    
    function MainNavigation() {
      const token = useRouteLoaderData("root");
      return (
        <header className={classes.header}>
          <nav>
            <ul className={classes.list}>
             ...
              {!token && (
                <li>
                  <NavLink
                    to="/auth"
                    className={({ isActive }) =>
                      isActive ? classes.active : undefined
                    }
                  >
                    Authentification
                  </NavLink>
                </li>
              )}
              {token && (
                <li>
                  <Form method="post" action="/logout">
                    <button>Logout</button>
                  </Form>
                </li>
              )}
            </ul>
          </nav>
          <NewsletterSignup />
        </header>
      );
    }
    
    export default MainNavigation;

     

    라우트 링크 보호하기

    로그인이 되지 않은 상태에서 폼 양식 제출 페이지로 가는 것을 막아야 할 필요가 있는데

    이때도 loader를 통해 해결하면 된다.

    export function checkAuthLoader() {
      const token = getAuthToken();
    
      if (!token) {
      	//토큰이 없을 경우 로그인 페이지로 리다이렉션 된다.
        return redirect("/auth");
      }
    	//항상 loader에는 반환 값이 있어야 한다.
      return null;
    }

     

    Main.jsx

    import { RouterProvider, createBrowserRouter } from "react-router-dom";
    
    ...
    import { checkAuthLoader, tokenLoader } from "../util/auth";
    
    const router = createBrowserRouter([
      {
        path: "/",
        element: <RootLayout />,
        errorElement: <ErrorPage />,
        id: "root",
        loader: tokenLoader,
        children: [
        		 ...
                  {
                    path: "edit",
                    element: <EditEventPage />,
                    action: manipulateEventAction,
                    //checkAuthLoader를 통해 막아준 모습
                    loader: checkAuthLoader,
                  },
                ],
              },
              {
                path: "new",
                element: <NewEventPage />,
                action: manipulateEventAction,
                 //checkAuthLoader를 통해 막아준 모습
                loader: checkAuthLoader,
              },
            ],
          },
          {
            path: "newsletter",
            element: <NewsletterPage />,
            action: newsletterAction,
          },
          {
            path: "logout",
            action: logoutAction,
          },
        ],
      },
    ]);
    
    function Certification() {
      return <RouterProvider router={router} />;
    }
    
    export default Certification;

     

    사용자 토큰 만료 시키기

    Authentication.jsx

    import AuthForm from "../components/AuthForm";
    import { json, redirect } from "react-router-dom";
    function AuthenticationPage() {
      return <AuthForm />;
    }
    
    export default AuthenticationPage;
    
    export async function action({ request }) {
      ...
      const resData = await response.json();
      const token = resData.token;
    
      localStorage.setItem("token", token);
      
      //만료 기간을 추가 한다.
      const expiration = new Date();
      //만료기간 +1시간으로 설정한다.
      expiration.setHours(expiration.getHours() + 1);
      localStorage.setItem("expiration", expiration.toISOString());
      return redirect("/");
    }

     

     

    auth.jsx

    //토큰 기간을 불러오는 함수
    export function getTokenDuration() {
      const storedExpirationDate = localStorage.getItem("expiration");
      //토큰 만료 기간
      const expirationDate = new Date(storedExpirationDate);
      //현재 시간
      const now = new Date();
      //지속 시간 = 만료시간 - 현재시간
      const duration = expirationDate.getTime() - now.getTime();
      return duration;
    }
    
    //토큰 만료 시 EXPIRED문자열 반환추가
    export function getAuthToken() {
      const token = localStorage.getItem("token");
      if(!token){
      //반환값이 있어야 오류가 발생하지 않는다.
        return null;
      }
      const tokenDuration = getTokenDuration();
      if(tokenDuration<0){
        return "EXPIRED";
      }
      return token;
    }

     

    가장 최상단 컴포넌트에서 토큰을 불러오고 세션을 만료시키면 된다.

     

    RootLayout.jsx

    import { Outlet, useLoaderData, useSubmit } from "react-router-dom";
    
    import MainNavigation from "../components/MainNavigation";
    import { useEffect } from "react";
    import { getTokenDuration } from "../../util/auth";
    
    function RootLayout() {
      // const navigation = useNavigation();
      const token = useLoaderData();
      const submit = useSubmit();
    
      useEffect(() => {
        if (!token) {
          return;
        }
        if (token === "EXPIRED") {
          submit(null, { action: "/logout" });
        }
        const tokenDuration = getTokenDuration();
        console.log(tokenDuration);
        setTimeout(() => {
          submit(null, { action: "/logout" });
        }, tokenDuration);
      }, [submit, token]);
      return (
        <>
          <MainNavigation />
          <main>
            {/* {navigation.state === 'loading' && <p>Loading...</p>} */}
            <Outlet />
          </main>
        </>
      );
    }
    
    export default RootLayout;

     

     

    아래와 같이 콘솔에 만료기간이 잘 찍히는 것을 볼 수 있다.

    반응형