Server-Side Session(서버 측 세션)
Authentication Tokens(인증 토큰)
import { Form, Link, useSearchParams } from "react-router-dom";
import classes from "./AuthForm.module.css";
function AuthForm() {
const [searchParams, setSearchParams] = useSearchParams();
// [ 현재 설정된 쿼리 매개변수에 접근권을 주는 객체, 현재 설정된 쿼리 매개변수를 업데이트하게 해주는 함수 ]
const isLogin = searchParams.get("mode") === "login"; // 가져오고싶은 쿼리 매개변수 비교. 만약 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}>
{/* 만약 이미 로그인 모드라면 signup모드로 갈 수 있게 해야한다. */}
<Link to={`?mode=${isLogin ? "signup" : "login"}`}>
{isLogin ? "Create new user" : "Login"}
</Link>
<button>Save</button>
</div>
</Form>
</>
);
}
export default AuthForm;
import { json, redirect } from "react-router-dom";
import AuthForm from "../components/AuthForm";
function AuthenticationPage() {
return <AuthForm />;
}
export default AuthenticationPage;
export async function action({ request, params }) {
const searchParams = new URL(request.url).searchParams;
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"),
};
const response = await fetch("http://localhost:8080/" + mode, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(authData),
});
if (response.status === 422 || response.status === 401) {
// 오류 코드를 받으면
return response;
}
if (!response.ok) {
throw json({ message: "사용자 인증 불가합니다." }, { status: 500 });
}
// 백엔드에서 얻는 토큰 관리할 예정
return redirect("/");
}
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import AuthenticationPage, {
action as authAction,
} from "./pages/Authentication";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
//...
{
path: "auth",
element: <AuthenticationPage />,
action: authAction, // 액션 추가
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
import {
Form,
Link,
useActionData,
useNavigation,
useSearchParams,
} from "react-router-dom";
import classes from "./AuthForm.module.css";
function AuthForm() {
const [searchParams, setSearchParams] = useSearchParams();
// [ 현재 설정된 쿼리 매개변수에 접근권을 주는 객체, 현재 설정된 쿼리 매개변수를 업데이트하게 해주는 함수 ]
const isLogin = searchParams.get("mode") === "login"; // 가져오고싶은 쿼리 매개변수 비교. 만약 mode=login이면 로그인모드에 있는 것.
const data = useActionData(); // 이 액션 데이터는 사용자가 인증할 때 발생한 문제와 관련한 정보를 담고있다.(문제발생시 리턴하기 때문)
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<>
<Form method="post" className={classes.form}>
<h1>{isLogin ? "Log in" : "Create a new user"}</h1>
{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">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}>
{/* 만약 이미 로그인 모드라면 signup모드로 갈 수 있게 해야한다. */}
<Link to={`?mode=${isLogin ? "signup" : "login"}`}>
{isLogin ? "Create new user" : "Login"}
</Link>
<button disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Save"}
</button>
</div>
</Form>
</>
);
}
export default AuthForm;

import { json, redirect } from "react-router-dom";
import AuthForm from "../components/AuthForm";
function AuthenticationPage() {
return <AuthForm />;
}
export default AuthenticationPage;
export async function action({ request, params }) {
const searchParams = new URL(request.url).searchParams;
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"),
};
const response = await fetch("http://localhost:8080/" + mode, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(authData),
});
if (response.status === 422 || response.status === 401) {
return response;
}
if (!response.ok) {
throw json({ message: "사용자 인증 불가합니다." }, { status: 500 });
}
// ===== backend에서 생성된 토큰을 받아와 로컬저장소에 저장 =====
const resData = await response.json();
const token = resData.token;
// 메모리에 저장할 수도 있고 쿠키에 저장할 수 있다.
localStorage.setItem("token", token); // 로컬저장소에 저장.
return redirect("/");
}
export function getAuthToken() {
const token = localStorage.getItem("token");
return token;
}
// ...
import { getAuthToken } from "../util/auth";
function EventDetailPage() {
//...
}
export default EventDetailPage;
async function loadEvent(id) {
//...
}
async function loadEvents() {
// ...
}
export async function loader({ request, params }) {
// ...
}
export async function action({ params, request }) {
const eventId = params.eventId;
const token = getAuthToken();
const response = await fetch("http://localhost:8080/events/" + eventId, {
method: request.method,
headers: {
// 토큰에 대한 Request 헤더 추가
Authorization: "Bearer " + token,
},
});
if (!response.ok) {
throw json(
{ message: "Could not delete event." },
{
status: 500,
}
);
}
return redirect("/events");
}

//...
import { getAuthToken } from "../util/auth";
function EventForm({ method, event }) {
//...
}
export default EventForm;
export async function action({ request, params }) {
const method = request.method;
const data = await request.formData();
const eventData = {
title: data.get("title"),
image: data.get("image"),
date: data.get("date"),
description: data.get("description"),
};
let url = "http://localhost:8080/events";
if (method === "PATCH") {
const eventId = params.eventId;
url = "http://localhost:8080/events/" + eventId;
}
const token = getAuthToken();
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
// Authentication을 위해 토큰 헤더 추가
Authorization: "Bearer " + token,
},
body: JSON.stringify(eventData),
});
if (response.status === 422) {
return response;
}
if (!response.ok) {
throw json({ message: "Could not save event." }, { status: 500 });
}
return redirect("/events");
}

import { redirect } from "react-router-dom";
export function action() {
localStorage.removeItem("token");
return redirect("/");
}
//...
import { action as logoutAction } from "./pages/Logout";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
//...
{
path: "logout",
action: logoutAction, // logout 액션 추가
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
<li>
<Form action="/logout" method="POST">
<button>Logout</button>
</Form>
</li>
Form의 action을 통해 해당 버튼이 눌렸을 때 '/logout' 라우트로 이동을 하게 된다. → 토큰을 제거 → 홈화면으로 리다이렉트
export function tokenLoader() {
return getAuthToken();
}
// ...
import { tokenLoader } from "./util/auth";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
id: "root",
loader: tokenLoader, // 로그아웃을 했는지 안했는지 알아 볼 수 있다.
//...
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
function MainNavigation() {
const token = useRouteLoaderData("root");
return (
{
!token && (
<li>
<NavLink
to="/auth?mode=login"
className={({ isActive }) => (isActive ? classes.active : undefined)}
>
Authentication
</NavLink>
</li>
);
}
{
token && (
<li>
<Form action="/logout" method="POST">
<button>Logout</button>
</Form>
</li>
);
}
)
}
function EventsNavigation() {
const token = useRouteLoaderData("root");
return (
{
token && (
<li>
<NavLink
to="/events/new"
className={({ isActive }) => (isActive ? classes.active : undefined)}
>
New Event
</NavLink>
</li>
);
}
)
}
function EventItem() {
const token = useRouteLoaderData("root");
return (
{
token && (
<menu className={classes.actions}>
<Link to="edit">Edit</Link>
<button onClick={startDeleteHandler}>Delete</button>
</menu>
);
}
)
}

export function checkAuthLoader() {
const token = getAuthToken();
if (!token) {
return redirect("/auth");
}
return null;
}
//...
import { tokenLoader, checkAuthLoader } from "./util/auth";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
id: "root",
loader: tokenLoader, // 로그아웃을 했는지 안했는지 알아 볼 수 있다.
children: [
{ index: true, element: <HomePage /> },
{
path: "events",
element: <EventsRootLayout />,
children: [
{
index: true,
element: <EventsPage />,
loader: eventsLoader,
},
{
path: ":eventId",
id: "event-detail",
loader: eventDetailLoader,
children: [
{
index: true,
element: <EventDetailPage />,
action: deleteEventAction,
},
{
path: "edit",
element: <EditEventPage />,
action: manipulateEventAction,
loader: checkAuthLoader, // 라우트 접근 제한 로더
},
],
},
{
path: "new",
element: <NewEventPage />,
action: manipulateEventAction,
loader: checkAuthLoader, // 라우트 접근 제한 로더
},
],
},
//...
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
import { Outlet, useLoaderData, useSubmit } from "react-router-dom";
import MainNavigation from "../components/MainNavigation";
import { useEffect } from "react";
function RootLayout() {
const token = useLoaderData();
const submit = useSubmit();
useEffect(() => {
if (!token) {
return;
}
const timer = setTimeout(() => {
submit(null, { action: "/logout", method: "POST" });
}, 1 * 60 * 60 * 1000); // 1000밀리초 * 60초 * 60분 * 1시간 = 1시간
return () => {
clearTimeout(timer);
};
}, [token, submit]);
return (
//...
);
}
export default RootLayout;
//...
export async function action({ request, params }) {
//...
// 토큰 만료 시간 저장할 필요가 있다.
const expiration = new Date();
expiration.setHours(expiration.getHours() + 1);
localStorage.setItem("expiration", expiration.toISOString());
return redirect("/");
}
// 토큰 만료시간 계산
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;
}
export function getAuthToken() {
const token = localStorage.getItem("token");
if (!token) {
return null;
}
const tokenDuration = getTokenDuration();
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 === "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 (
//...
);
}
export default RootLayout;
import { redirect } from "react-router-dom";
export function action() {
localStorage.removeItem("token");
localStorage.removeItem("expiration");
return redirect("/");
}
