[React-Router] 이벤트 앱 만들기 연습 : Auth 사용자 인증하기_회원가입, 로그인, 로그아웃, 자동 로그아웃

summereuna🐥·2023년 6월 21일
0

React JS

목록 보기
66/69

Authentication

  1. 리액트 앱에서 인증이 구동되는 원리(How Authentication Works In React Apps)
  2. ✅ 사용자 인증 실행(Implementing User Authentication)
    • 프론트엔드의 리액트 앱이 인증을 시행하는 백엔드와 어떻게 소통하는지
  3. ✅인증 지속성과 자동 로그아웃 추가(Adding Auth Persistence & Auto-Logout)
    • 인증 지속성: 사용자가 로그인 되어 있는지 아닌지 추적
    • 자동 로그아웃: 일정 시간이 지나면 자동으로 사용자를 로그아웃 시킴
    • 리액트 앱에 인증 추가하는 방법

✅ 회원가입 프로세스


프론트엔드에서 AuthForm이 전송되면 백엔드에서 가입 라우트(사용자 생성하는 라우트) 트리거된다.
즉 가입 라우트에 POST 요청 보내지는데, 그러면 아래 단계를 거치게 된다.

  1. 사용자가 입력한 값(이메일, 비밀번호) 검증
    • 검증 실패 시 오류 메시지 리턴
  2. 검증 성공 시 사용자 생성하여 데이터베이스에 저장(여기선 /backend/event.json 파일에 저장)
  3. 해당 정보에 맞는 토큰 생성되어 백엔드에서 프론트엔드로 전송

1. AuthenticationPage에 액션 작성


AuthForm이 있는 라우트와 동일한 라우트인 AuthenticationPage에 액션을 작성하여 AuthForm의 Form이 전송될 때 마다 해당 액션이 트리거되게 하면된다.

액션 작성

  1. 쿼리 매개변수 확인하여 mode 알아내기
  2. 사용자가 입력한 폼 데이터 가져오기
  3. 1, 2에서 얻은 값으로 fetch()하여 응답 얻기
  4. 응답 처리 실패 시 응답 리턴하여 폼에 표시
  5. 응답 자체 오류시 에러 출력
  6. (❗️아직 안함) 응답 성공시 백엔드에서 얻는 토큰 관리
  7. 응답 성공 시 홈으로 리다이렉트

📍/src/pages/Authentication.js

import { json, redirect } from "react-router-dom";
import AuthForm from "../components/AuthForm";

function AuthenticationPage() {
  return <AuthForm />;
}

export default AuthenticationPage;

//🔥 액션 작성
export const action = async ({ request }) => {
  //✅ 쿼리 매개변수 확인 위해 브라우저 내장 URL 생성자 함수 사용하여 searchParams 객체에 접근
  const searchParams = new URL(request.url).searchParams;
  //mode 잡아오고, 아직 undefined일 경우 기본 값을 login으로 설정
  const mode = searchParams.get("mode") || "login"
  
  //혹시나 대비: 다른 모드 임의 입력 방지
  if (mode !== "login" && mode !== "signup") {
    throw json({ message: "미지원 모드입니다." }, { status: 422 });
  }

  //✅ 사용자가 입력한 폼 데이터에 액세스
  const data = await request.formData();
  const authData = {
    email: data.get("email"),
    password: data.get("password"),
  };

  //✅ 응답
  //mode에 따라 페치하는 url 다름
  const response = await fetch(`http://localhost:8080/${mode}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(authData),
    //JSON 포맷으로 변환하여 사용자 입력 데이터 보내기
  });

  //✅ 응답 처리 코드
  //유효하지 않은 자격 증명으로 로그인 시도하여 백엔드에서 오류 받은 경우
  //authForm에 데이터 리턴하여 오류 메시지 보여주기 위해 response 리턴
  if (response.status === 422 || response.status === 401) {
    return response;
  }

  //✅ 응답 자체가 오류인 경우
  if (!response.ok) {
    throw json({ message: "사용자 인증 불가" }, { status: 500 });
  }

  //✅ 응답 성공시 백엔드에서 얻는 토큰 관리...는 나중에 작성

  
  //✅ 성공시 사용자 홈으로 리다이렉트
  return redirect("/");
};

2. 라우트에 액션 추가


📍/src/App.js

import AuthenticationPage, {
  action as authAction,
} from "./pages/Authentication";

//...
{
  path: "auth",
    element: <AuthenticationPage />,
      action: authAction,
},

3. AuthForm에서 오류 메시지 출력하기


응답 처리 실패 시 응답 리턴하여 폼에 표시할 때 useActionData()로 리턴된 데이터를 잡아올 수 있다.

데이터가 있고 오류가 있다면 데이터의 오류를 JS 내장 메서드인 Object.values()를 활용하여 errors 객체의 모든 값을 살피고 오류를 출력할 수 있다.

📍/src/components/AuthForm.js

import {
  Form,
  Link,
  useActionData,
  useNavigation,
  useSearchParams,
} from "react-router-dom";

import classes from "./AuthForm.module.css";

function AuthForm() {
  //status 422/401이 나올 경우(사용자 인증 문제 발생) 해당 양식 전송한 작업 함수가 리턴한 데이터
  const data = useActionData();

  const [searchParams] = useSearchParams();
  const isLogin = searchParams.get("mode") === "login";

  //제출중인거 표시
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <>
      <Form method="post" className={classes.form}>
        <h1>{isLogin ? "로그인" : "회원가입"}</h1>
        {/* data가 있고 오류가 발생한게 맞다면, Object.values()를 활용해 errors 객체의 모든 값을 살피기 */}
        {data && data.errors && (
          <ul>
            {Object.values(data.errors).map((err) => (
              <li key={err}>{err}</li>
            ))}
          </ul>
        )}
        {data && data.message && <p>{data.message}</p>}
        <p>
          <label htmlFor="email">이메일</label>
          <input id="email" type="email" name="email" required />
        </p>
        <p>
          <label htmlFor="image">비밀번호</label>
          <input id="password" type="password" name="password" required />
        </p>
        <div className={classes.actions}>
          <Link to={`?mode=${isLogin ? "signup" : "login"}`}>
            {isLogin ? "회원가입" : "로그인"}
          </Link>
          <button disabled={isSubmitting}>
            {isSubmitting ? "제출 중..." : "제출"}
          </button>
        </div>
      </Form>
    </>
  );
}

export default AuthForm;
  • 오류 메시지

✅ 로그인 프로세스


mode에 따라 회원가입/로그인 fetch를 하고 있기 때문에 로그인 프로세스는 이미 잘 작동된다.
하지만 아직 토큰을 받아오고 있지 않기 때문에 로그인을 해도 로그인이 유지되지 않고, 이벤트 수정 및 삭제도 할 수 없다.

🌟 내보내는 요청에 인증 토큰 첨부하기

보호된 리소스로 보내는 요청에는 백엔드에서 돌아오는 토큰을 반드시 첨부해야 한다.

현재 이벤트를 수정하거나 삭제하려면 권한이 있어야 한다고 백엔드에서 설정해 두었는데, 아직 로그인을 해도 토큰이 없기 때문에 이벤트 수정 및 삭제 권한이 없는 상태이다.

  • 이벤트 삭제하려고 하면 승인되지 않았기 때문에 에러가 발생한다. 내보내는 요청에 토큰이 아직 존재하지 않기 때문이다.

따라서 내보내는 요청에 인증 토큰을 첨부하기 위해서는 가입/로그인 시 응답을 받으면 백엔드에서 받은 토큰을 추출하여 저장해야 한다.
그래야 내보내는 요청에 토큰을 첨부하여 사용할 수 있다.

1. 가입/로그인 하는 곳에 백엔드에서 받은 토큰 추출하여 로컬 저장소에 저장하기

📍/src/pages/Authentication.js

  //응답 성공시 백엔드에서 얻는 토큰 관리
  //응답에서 토큰 추출하기
  const resData = await response.json();
  //console.log(resData); 찍어보면 토큰이 보인다.
  const token = resData.token;
  //로컬저장소에 토큰 저장
  localStorage.setItem("token", token);

  return redirect("/");
};
  • console.log(resData); 찍어보면 토큰이 보인다.

토큰을 어디에 저장해야 할까?

  • 메모리 혹은 쿠키

  • 아니면 단순히 브라우저 API인 로컬 저장소에 저장해도 된다.

  • localStorage.setItem("token", token);
    기억하자! 현재 작업하고 있는 이 액션 코드는 브라우저에서 구동된다.
    따라서 모든 표준 브라우저 기능을 사용할 수 있기 때문에 로컬저장소에 접근하여 새 항목을 설정해 해당 토큰을 브라우저 저장소에 저장할 수 있다.

  • 이렇게 로컬 스토리지에 토큰이 저장된다.

2. 로컬저장소에서 토큰 꺼내오기

요청을 내보낼 때 토큰을 사용하려면, 로컬저장소에 있는 토큰을 꺼내 쓰면 된다.
/src/util/auth.js를 만들고 로컬저장소에서 꺼내오는 함수를 만들어 내보내자.

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

이제 토큰이 필요한 곳에서 getAuthToken 함수를 사용하면 된다.

3. 삭제/편집/추가 하는 액션에 토큰 추가하여 권한 인증하기

삭제/편집/추가 하는 액션에서 getAuthToken 함수를 사용하여 로컬스토리지에서 토큰을 가져오자.
그리고 데이터를 fetch 할때 다음과 같은 특별한 헤더를 추가하여 요청을 보낼 때 토큰을 보내 권한을 인증하자.

//토큰 얻어오기 
const token = getAuthToken();


headers: {
  Authorization: `Bearer ${token}`,
},

Bearer 옆에 화이트 스페이스 꼭 있어야 함!

이렇게 백엔드에서 보호되는 모든 라우트에 토큰을 추가할 수 있다.
생성/편집/삭제 라우트가 보호되고 있다.


✅ 로그아웃 프로세스


1. 로그아웃 액션 생성

로그아웃 버튼을 클릭하면 로컬스토리지에서 토큰을 삭제하여 로그아웃되게 할 수 있는데, 단순히 클릭 리스너를 추가하여 삭제할 수도 있지만 좀 더 공식적인 리액트 라우팅 식의 접근법을 생각해 보자.

/src/pages/Logout.js 로그아웃 페이지를 생성하여 로컬스토리지에서 토큰을 삭제하는 액션을 만들고 그 액션을 익스포트 하자.

  • 실제로 로그아웃 페이지라는 것은 없기 때문에 이 파일에는 그 어떤 컴포넌트도 없다.
  • 대신 로컬 저장소를 정리하여 토큰을 삭제하는 액션 함수를 생성하고 익스포트하자.
import { redirect } from "react-router-dom";

export const action = () => {
  localStorage.removeItem("token");
  return redirect("/");
};

2. 로그아웃 라우트 추가

  • 로그아웃으로 패스 연결 시 logoutAction이 작동되게 한다.
import { action as logoutAction } from "./pages/Logout";

//...

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

3. 네비게이션에 로그아웃 버튼을 만들고 method, action설정

  • react-router-dom의 Form을 사용하여 method="POST"로 설정한다.
  • action="/logout"로 설정하여 버튼 클릭 시 로그아웃 라우트로 보낸다.
<li>
  <Form action="/logout" method="POST">
    <button>로그아웃</button>
  </Form>
</li>

✅ 토큰 유무에 따른 UI 업데이트


토큰 유무에 따라 UI를 업데이트하려면, 기본적으로 전체 애플리케이션의 모든 라우트에서 토큰을 쉽게 사용할 수 있게 만들어야 한다.
토큰이 있는지 없는지에 대한 상태가 자동으로 업데이트되어 로그아웃으로 토큰이 삭제되면 자동으로 토큰 상태가 업데이트 되게 만들어야 한다.

따라서 굳이 네비게이션에서 getAuthToken()함수를 호출하여 토큰을 얻지 말자.
이 getAuthToken 함수는 MainNavigation 컴포넌트가 재 평가될 때만 호출되기 때문에, 토큰이 삭제되고 나서는 컴포넌트가 자동으로 재평가되지 않는다.

따라서 앱 전반에 걸쳐 토큰을 관리하는 좀 더 유연한 솔루션이 필요하다!

현재 사용하고 있는 리액트 라우터로는 어떻게 구현할 수 있을까?

  • 루트 라우트로 자식 라우트들을 감싸고 있는데, 루트 라우트에 로더를 추가하여 토큰을 추출하면, 그 아래 있는 모든 라우트에서 루트 라우트의 로더 데이터를 사용할 수 있다.
  • 루트 라우트에 토큰을 추출하는 로더를 추가한 후 로그아웃을 하면, 로그아웃 양식이 전송되고 리액트 라우터가 알아서 컴포넌트들 재평가 해준다.
    해당 토큰을 다시 fetch하여 토큰이 없는 것을 확인하고, 루트 라우트의 로더 데이터를 사용하는 모든 페이지를 업데이트 해준다.

1. 토큰을 얻는 로더 생성하여 내보내기

📍/src/util/auth.js

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

//✅ 토큰 얻는 로더 생성하여 내보내기
export const tokenLoader = () => {
  return getAuthToken();
};

2. root 라우트에 로더 추가

새로운 네비게이션 액션 발생할 때 마다 토큰을 얻는 로더가 호출된다.

  • 예) 로그아웃 트리거될 때
  • 사용자가 form을 전송하거나 메뉴를 누르거나 할때 등, 현재 상태를 확인하여 토큰 상태에 관한 최신 정보를 확보할 수 있다.

📍/src/App.js

//...
import { tokenLoader, checkAuthLoader } from "./util/auth";

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    id: "root", //✅ 루트의 로더 사용하기 위해 id 추가
    loader: tokenLoader, //✅ 로더 추가
    children: [

3. 토큰이 필요한 자식 라우트에서 useRouteLoaderData("root")로 토큰 얻어와 UI에 적용

import { Form, NavLink, useRouteLoaderData } from "react-router-dom";
//...
function MainNavigation() {
  const token = useRouteLoaderData("root");

  return (
    //...
          </li>
          {!token && (
            <li>
              <NavLink
                to="/auth?mode=login"
                className={({ isActive }) =>
                  isActive ? classes.active : undefined
                }
              >
                로그인
              </NavLink>
            </li>
          )}
          {token && (
            <li>
              <Form action="/logout" method="POST">
                <button>로그아웃</button>
              </Form>
            </li>
          )}
        </ul>
        //...

✅ 라우트 보호하기


토큰 없는 상태, 즉 로그인하지 않은 상태에서 url을 직접 입력하면 진입이 가능하다.
비록 토큰이 없기 때문에 data를 fetch 할 수는 없지만, 애초에 토큰이 없다면 라우트에 들어가지도 못하게 막아보자!

라우트를 보호할 때도 라우트에 해당 라우트로 접근을 막는로더를 생성하여 라우트에 추가할 수 있다.

1. 토큰 있는지 확인하는 로더 생성하여 내보내기

📍/src/util/auth.js

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

export const tokenLoader = () => {
  return getAuthToken();
};

//✅ 토큰 있는지 확인하는 로더 생성하여 내보내기
export const checkAuthLoader = () => {
  //토큰 유무 확인해 없으면 /auth로 리다이렉트 시키기
  const token = getAuthToken();
  if (!token) {
    return redirect("/auth?mode=signup");
  }

  //토큰 있는 경우에는 아무 것도 안하도록 꼭 null을 반환하자!
  return null;
}

2. 보호해야할 라우트에 토큰 있는지 확인하는 로더 추가

📍/src/App.js

import { tokenLoader, checkAuthLoader } from "./util/auth";

//...

{
  path: "edit",
  element: <EditEventPage />,
  action: manipulateEventAction,
  loader: checkAuthLoader, //라우트 보호
},
  
  //..
  
{
  path: "new",
  element: <NewEventPage />,
  action: manipulateEventAction,
  loader: checkAuthLoader, //라우트 보호
},

✅ 자동 로그아웃 프로세스


  • 현재 백엔드에서는 1시간 후에 만료되는 토큰을 생성하고 있다.

보안상 이유료 보통 토큰의 수명은 상대적으로 짧다.
더 고급 수준의 인증 흐름과 설정을 생성할 수도 있지만 일단 여기서는 요렇게 연습 중이니까..!

1시간 뒤에 사용자 로그아웃 및 로컬스토리지에서 토큰을 삭제시켜 UI도 그에 따라 업데이트되게 해보자.

Root.js에서 useEffect()훅을 사용하여 앱이 시작되고 RootLayout 랜더링되면 타이머를 설정하여 처리하기

이 방법은 자식 라우트를 감싸고 있는 Root.js 페이지에서 가장 최상단에 위치하여 가장 먼저 로딩되는 컴포넌트인 RootLayout가 있기 때문에 가능한 방법이다.

📍/src/pages/Root.js

//...

function RootLayout(){
  //루트 라우트에 렌더링 되는 컴포넌트이기 때문에
  //useRouteLoaderData("root")가 아닌
  //useLoaderData()로 가져올 수 있음
  const token = useLoaderData();
  
  //계획적으로 양식 전송하는 데 사용하는 useSubmit()
  //이 훅으로 로그아웃 양식 전송하여 로그아웃 요청 보내기
  const submit = useSubmit();
  
  useEffect(() => {
    
    //토큰이 없으면 할게 없으므로 그냥 리턴
    if(!token){
      return;
    }
    
    //토큰 있는 경우 1시간 타이머 설정하여 1시간 뒤 로그아웃 트리거
    if(token){
      setTimeout(() => {
        submit(null, { action: "/logout", method: "POST" })
        //전송할 데이터는 없음, 액션은 로그아웃 라우트에 타겟팅
      }, 1 * 60 * 60 * 1000) // 한시간 ms초 인식 = 1h * 60m * 60s * 1000ms
    }
  }, [token, submit]); //token, submit이 바뀌면 이펙트 함수 작동
  
  return (
  //...
  );
}

하지만 문제가 있다.

만약 로그인 후 10분 간 자리 비운 후 이 애플리케이션을 다시 로딩할 경우 effect함수가 다시 트리거되면서 타이머가 다시 한 시락으로 리셋된다.

  • 그런데 10분 전에 로그인 했기 때문에 로컬 저장소에는 토큰이 있는 상태이다.
    50분 후에 토큰은 만료되기 때문에, 50분 후 백엔드에서는 토큰이 없다고 사용자에게 권한을 주지 않게 된다.
  • 하지만 그 시각, 타이머는 아직 10분이 남아 있다.

따라서 타임아웃을 1시간으로 설정하는 것만으로는 오류가 발생한다.
실질적인 토큰 만료를 관리하고 등록해야 하기 때문에 이에 더해 인증 시 토큰 저장하는 액션에 유효 시간을 저장하자.

  • 시간 차 오류 없애기 위해 토큰 생성 시간에 1시간을 더한 값을 로컬스토리지에 저장한 후, 만료 시각과 현 시각의 차이를 구해 만료 기간을 구한 후, 만료 기간을 타임아웃에 적용해 보자.

1. 로그인 시 토큰 생성될 때, 1시간 뒤 토큰 만료되도는 시각 계산하여 로컬스토리지에 만료 시각 저장

📍/src/pages/Authentication.js

//...
export const action = async ({ request }) => {
  //...

  const resData = await response.json();
  const token = resData.token;

  localStorage.setItem("token", token);

  //✅ 1시간 뒤 토큰 만료되도록 계산하여 로컬스토리지에 저장
  //자바스크립트 내장 객체인 new Date()로 만료 날짜 만들고
  const expiration = new Date();
  //만료 날짜에 setHours() 메서드 호출하여, 만료 날짜의 시간에 1시간을 더한 값을 설정한다.
  expiration.setHours(expiration.getHours() + 1);
  //toISOString()으로 만료일을 표준화된 스트링으로 변환하여 로컬스토리지에 저장한다.
  localStorage.setItem("expiration", expiration.toISOString());

  return redirect("/");
};

2. 만료 날짜 살펴보고 만료 여부 확인하는 함수 생성하여 getAuthToken() 수정

📍/src/util/auth.js

import { redirect } from "react-router-dom";

//만료날짜 살펴보고 만료 여부 확인하기
export const getTokenDuration = () => {
  //로컬스토리지에서 저장된 만료 날짜 가져오기
  const storedExpirationDate = localStorage.getItem("expiration");
  //Date 객체로 변환
  const expirationDate = new Date(storedExpirationDate);

  //현 시각
  const now = new Date();

  //잔여 유효 기간: getTime()은 ms초 단위 리턴해줌
  //만료 시각 타임스탬프 - 현재 시각 타임스탬프
  const duration = expirationDate.getTime() - now.getTime();
  //유효하면 양수(+), 토큰 만료되면 음수(-)

  return duration;
};


export const getAuthToken = () => {
  const token = localStorage.getItem("token");

  //토큰이 아에 없으면 아무 것도 리턴하지 않기(undefined 안되게 null 리턴)
  if (!token) {
    return null;
  }

  const tokenDuration = getTokenDuration();

  //토큰이 존재하면 만료 시기 확인
  //토큰 만료 된 경우 (음수) token에 만료("EXPIRED") string 반환
  if (tokenDuration < 0) {
    return "EXPIRED";
  }

  return token;
};

//...

3. 루트 페이지에 토큰 만료기간 재설정

📍/src/pages/Root.js

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 token = useLoaderData();
  const submit = useSubmit();

  useEffect(() => {
    //토큰이 없으면 반환
    if (!token) {
      return;
    }

    //토큰이 있다면
    if (token) {
      //토큰이 만료되면 로그아웃 트리거하고 반환
      if (token === "EXPIRED") {
        submit(null, { action: "/logout", method: "POST" });
        return;
      }

      //아직 만료 기간 남아 있으면
      const tokenDuration = getTokenDuration();
      console.log(tokenDuration);

      setTimeout(() => {
        submit(null, { action: "/logout", method: "POST" });
      }, tokenDuration); //남은 만료 시간으로 타임아웃 시간 변경
    }
  }, [token, submit]);

  return (
    <>
      <MainNavigation />
      <main>
        <Outlet />
      </main>
    </>
  );
}

export default RootLayout;

4. 로그아웃 시 로컬스토리지에 만료 키 제거

📍/src/pages/Logout.js

import { redirect } from "react-router-dom";

export const action = () => {
  localStorage.removeItem("token");
  localStorage.removeItem("expiration"); // 로그아웃시 만료 키도 제거
  return redirect("/");
};
profile
Always have hope🍀 & constant passion🔥

0개의 댓글