프론트엔드에서 AuthForm이 전송되면 백엔드에서 가입 라우트(사용자 생성하는 라우트) 트리거된다.
즉 가입 라우트에 POST 요청 보내지는데, 그러면 아래 단계를 거치게 된다.
/backend/event.json
파일에 저장)AuthForm이 있는 라우트와 동일한 라우트인 AuthenticationPage에 액션을 작성하여 AuthForm의 Form이 전송될 때 마다 해당 액션이 트리거되게 하면된다.
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("/");
};
import AuthenticationPage, {
action as authAction,
} from "./pages/Authentication";
//...
{
path: "auth",
element: <AuthenticationPage />,
action: authAction,
},
응답 처리 실패 시 응답 리턴하여 폼에 표시할 때 useActionData()
로 리턴된 데이터를 잡아올 수 있다.
데이터가 있고 오류가 있다면 데이터의 오류를 JS 내장 메서드인 Object.values()
를 활용하여 errors 객체의 모든 값을 살피고 오류를 출력할 수 있다.
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를 하고 있기 때문에 로그인 프로세스는 이미 잘 작동된다.
하지만 아직 토큰을 받아오고 있지 않기 때문에 로그인을 해도 로그인이 유지되지 않고, 이벤트 수정 및 삭제도 할 수 없다.
보호된 리소스로 보내는 요청에는 백엔드에서 돌아오는 토큰을 반드시 첨부해야 한다.
현재 이벤트를 수정하거나 삭제하려면 권한이 있어야 한다고 백엔드에서 설정해 두었는데, 아직 로그인을 해도 토큰이 없기 때문에 이벤트 수정 및 삭제 권한이 없는 상태이다.
따라서 내보내는 요청에 인증 토큰을 첨부하기 위해서는 가입/로그인 시 응답을 받으면 백엔드에서 받은 토큰을 추출하여 저장해야 한다.
그래야 내보내는 요청에 토큰을 첨부하여 사용할 수 있다.
//응답 성공시 백엔드에서 얻는 토큰 관리
//응답에서 토큰 추출하기
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);
기억하자! 현재 작업하고 있는 이 액션 코드는 브라우저에서 구동된다.
따라서 모든 표준 브라우저 기능을 사용할 수 있기 때문에 로컬저장소에 접근하여 새 항목을 설정해 해당 토큰을 브라우저 저장소에 저장할 수 있다.
이렇게 로컬 스토리지에 토큰이 저장된다.
요청을 내보낼 때 토큰을 사용하려면, 로컬저장소에 있는 토큰을 꺼내 쓰면 된다.
/src/util/auth.js
를 만들고 로컬저장소에서 꺼내오는 함수를 만들어 내보내자.
export const getAuthToken = () => {
const token = localStorage.getItem("token");
return token;
};
이제 토큰이 필요한 곳에서 getAuthToken 함수를 사용하면 된다.
삭제/편집/추가 하는 액션에서 getAuthToken 함수를 사용하여 로컬스토리지에서 토큰을 가져오자.
그리고 데이터를 fetch 할때 다음과 같은 특별한 헤더를 추가하여 요청을 보낼 때 토큰을 보내 권한을 인증하자.
//토큰 얻어오기
const token = getAuthToken();
headers: {
Authorization: `Bearer ${token}`,
},
Bearer 옆에 화이트 스페이스 꼭 있어야 함!
이렇게 백엔드에서 보호되는 모든 라우트에 토큰을 추가할 수 있다.
생성/편집/삭제 라우트가 보호되고 있다.
로그아웃 버튼을 클릭하면 로컬스토리지에서 토큰을 삭제하여 로그아웃되게 할 수 있는데, 단순히 클릭 리스너를 추가하여 삭제할 수도 있지만 좀 더 공식적인 리액트 라우팅 식의 접근법을 생각해 보자.
/src/pages/Logout.js
로그아웃 페이지를 생성하여 로컬스토리지에서 토큰을 삭제하는 액션을 만들고 그 액션을 익스포트 하자.
import { redirect } from "react-router-dom";
export const action = () => {
localStorage.removeItem("token");
return redirect("/");
};
import { action as logoutAction } from "./pages/Logout";
//...
{
path: "logout",
action: logoutAction,
},
method="POST"
로 설정한다.action="/logout"
로 설정하여 버튼 클릭 시 로그아웃 라우트로 보낸다.<li>
<Form action="/logout" method="POST">
<button>로그아웃</button>
</Form>
</li>
토큰 유무에 따라 UI를 업데이트하려면, 기본적으로 전체 애플리케이션의 모든 라우트에서 토큰을 쉽게 사용할 수 있게 만들어야 한다.
토큰이 있는지 없는지에 대한 상태가 자동으로 업데이트되어 로그아웃으로 토큰이 삭제되면 자동으로 토큰 상태가 업데이트 되게 만들어야 한다.
따라서 굳이 네비게이션에서 getAuthToken()함수를 호출하여 토큰을 얻지 말자.
이 getAuthToken 함수는 MainNavigation 컴포넌트가 재 평가될 때만 호출되기 때문에, 토큰이 삭제되고 나서는 컴포넌트가 자동으로 재평가되지 않는다.
따라서 앱 전반에 걸쳐 토큰을 관리하는 좀 더 유연한 솔루션이 필요하다!
export const getAuthToken = () => {
const token = localStorage.getItem("token");
return token;
};
//✅ 토큰 얻는 로더 생성하여 내보내기
export const tokenLoader = () => {
return getAuthToken();
};
새로운 네비게이션 액션 발생할 때 마다 토큰을 얻는 로더가 호출된다.
//...
import { tokenLoader, checkAuthLoader } from "./util/auth";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
id: "root", //✅ 루트의 로더 사용하기 위해 id 추가
loader: tokenLoader, //✅ 로더 추가
children: [
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 할 수는 없지만, 애초에 토큰이 없다면 라우트에 들어가지도 못하게 막아보자!
라우트를 보호할 때도 라우트에 해당 라우트로 접근을 막는로더를 생성하여 라우트에 추가할 수 있다.
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;
}
import { tokenLoader, checkAuthLoader } from "./util/auth";
//...
{
path: "edit",
element: <EditEventPage />,
action: manipulateEventAction,
loader: checkAuthLoader, //라우트 보호
},
//..
{
path: "new",
element: <NewEventPage />,
action: manipulateEventAction,
loader: checkAuthLoader, //라우트 보호
},
보안상 이유료 보통 토큰의 수명은 상대적으로 짧다.
더 고급 수준의 인증 흐름과 설정을 생성할 수도 있지만 일단 여기서는 요렇게 연습 중이니까..!
1시간 뒤에 사용자 로그아웃 및 로컬스토리지에서 토큰을 삭제시켜 UI도 그에 따라 업데이트되게 해보자.
이 방법은 자식 라우트를 감싸고 있는 Root.js 페이지에서 가장 최상단에 위치하여 가장 먼저 로딩되는 컴포넌트인 RootLayout가 있기 때문에 가능한 방법이다.
//...
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함수가 다시 트리거되면서 타이머가 다시 한 시락으로 리셋된다.
따라서 타임아웃을 1시간으로 설정하는 것만으로는 오류가 발생한다.
실질적인 토큰 만료를 관리하고 등록해야 하기 때문에 이에 더해 인증 시 토큰 저장하는 액션에 유효 시간을 저장하자.
//...
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("/");
};
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;
};
//...
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;
import { redirect } from "react-router-dom";
export const action = () => {
localStorage.removeItem("token");
localStorage.removeItem("expiration"); // 로그아웃시 만료 키도 제거
return redirect("/");
};