웹의 인증 방식
웹에서 인증을 구현하는 방법은 두가지가 있다.
서버사이드 세션
프론트엔드와 백엔드가 구분되지 않은 애플리케이션에서 자주 이용된다.
사용자가 로그인했을 때 서버에 고유 식별자를 저장하고 사용자에게도 보내준다. 이후 요청에서 그 식별자를 이용해서 인증한다.
백엔드와 프론트엔드가 긴밀하게 결합돼 있어야 한다. 클라이언트 관련 정보를 서버에 저장해야 하기 때문이다.
인증 토큰
사용자가 인증받은 뒤에는 허가 토큰을 생성하지만 서버에 저장은 하지 않고 사용자에게 보낸다.
백엔드만이 이 토큰의 유효성을 검사할 수 있다. 이후 요청에서 사용자가 토큰을 제시하면 백엔드에서 이 토큰의 유효성을 판단하는 방식이다.
이번에 구현해 볼 인증 방식은 토큰을 이용한 방식이다. 구현할 기능들은 다음과 같다.
・ 신규 가입하면 가입 정보를 서버에 저장
・ 로그인/로그아웃 기능
・ 로그인 시 부여된 토큰 소유 여부에 따라서 UI 업데이트
・ 로그인되지 않은 사용자로부터 특정 라우트 보호
・ 토큰이 만료됐을 때 사용자를 로그아웃시키기
리액트 앱에 인증 추가하기
일단 저번 포스팅에서 다뤘던 리액트 라우터를 이용해서 각 페이지들을 연결해 줬다.
//App.js
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
//여기에서 로드해 온 정보는 하위의 모든 라우트에서 사용 가능하다.
//사용자가 페이지에서 어떤 활동을 할 때 이 부분에서 현재 상태를 확인하기 때문에
//토큰의 최신 상태를 받아올 수 있다.
loader: tokenLoader,
id: "root",
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,
},
],
},
{
path: "auth",
element: <AuthenticationPage />,
action: authAction,
},
{
path: "newsletter",
element: <NewsletterPage />,
action: newsletterAction,
},
{
path: "logout",
action: logoutAction,
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
엄.. 코드가 너무 길어서 이게 맞나 싶지만 어쨌든.. 루트 경로를 시작으로 해서 각각의 라우터들에 컴포넌트가 연결된다. 컴포넌트의 역할에 따라서 loader나 action을 전달해 준다.
가장 중요한 loader는 인증 토큰을 확인하는 tokenLoader로, 애플리케이션 전반에서 현재 사용자의 로그인 상태를 확인해서 ui를 업데이트해줘야 하기 때문에 루트 경로에 loader를 두었다. loader에서 받아온 데이터는 그 컴포넌트와 같거나 더 낮은 수준의 컴포넌트에서만 useLoaderData() 또는 useRouteLoaderData()로 접근 가능하기 때문에 루트 경로에 둬야만 그 하위 컴포넌트들이 모두 데이터를 받아서 쓸 수 있다.
이 부분에서 로그인 여부에 따른 라우트 보호를 설정할 수 있다. 예컨대 새로운 이벤트를 등록하는 페이지나 이미 있는 이벤트를 수정하는 페이지는 회원이 아닌 경우에는 연결될 수 없게 해야 한다. 이벤트 등록 및 수정 요청이 제출될 때 토큰을 함께 첨부하도록 했기 때문에 로그인이 돼 있지 않다면 수정을 할 수 없기는 하지만, 아예 해당 페이지로 연결이 되지 않도록 하는 것이 더 안전하고 이용자들에게도 혼란을 주지 않을 것이다.
따라서 로그인되지 않은 사용자가 로그인이 필요한 라우터에 접근하려 할 경우 라우터를 보호하고 리다이렉트를 시키기 위해 현재 사용자가 로그인시에 부여되는 토큰이 있는지 확인하고, 토큰이 없다면 로그인 페이지나 에러 페이지로 리다이렉트하는 checkAuthLoader 함수를 loader 프로퍼티에 담아 준다.
//Authentification.js
export async function action({ request }) {
//url을 받아와서 mode를 확인하고 유효하지 않은 url이라면 에러를 반환한다.
const searchParams = new URL(request.url).searchParams;
const mode = searchParams.get("mode") || "login";
if (mode !== "login" && mode !== "signup") {
throw json({ message: "Unsupported mode." }, { status: 422 });
}
//request.formData()로 제출된 데이터에 접근
const data = await request.formData();
const authData = {
email: data.get("email"),
password: data.get("password"),
};
//url 모드에 따라서 회원가입 또는 로그인 요청을 서버에 보낸다.
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: "Could not authenticate user." }, { status: 500 });
}
//회원가입 또는 로그인 절차가 끝나면 홈으로 리다이렉트
return redirect("/");
}
회원가입과 로그인을 처리하는 action 부분의 코드다. url에 들어 있는 쿼리 파라미터에 접근하기 위해서 URLsearchParams 객체를 사용했다.
URL은 WHATWG 방식에 따라 다음과 같은 구조로 구분할 수 있다.
이 중에서 searchParams가 확인하는 부분은 search 부분이다. URLsearchParams로부터 get, getAll로 값을 가져오고, append로 새 값을 추가하고, set으로 값을 새 값으로 교체할 수 있다. 여기서는 get 메서드만 사용했다.
인증 컴포넌트에서 입력된 이메일과 비밀번호를 formData()로 가져오고, 그 데이터를 url 모드에 따라서 회원가입 또는 로그인 요청을 보내는 요청 바디에 담아서 보낸다.
사용자가 작성하는 폼은 리액트 라우터에서 제공되는 Form 컴포넌트를 사용했다. 리액트 라우터의 Form 컴포넌트는 action과 함께 쓰여 서버에 폼 데이터를 제출할 수 있게 해준다. 위 Authentification.js에서 정의한 액션 함수가 App.js에서 라우트 정의를 통해 연결되고, 폼에서는 useActionData 훅을 통해 이전 내비게이션 활동의 결과로 제출된 폼 데이터의 유효성을 확인한다. 만약 아직 제출된 내역이 없다면 useActionData가 반환하는 값은 undefined다.
//AuthForm.js
function AuthForm() {
//이전 내비게이션 활동의 결과로 폼에 제출된 데이터를 받아와 유효성을 판단한다.
const data = useActionData();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const isLogin = searchParams.get("mode") === "login";
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>
)}
<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 disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Save"}
</button>
</div>
</Form>
</>
);
}
서버에서 부여된 토큰은 보안을 위해서 일반적으로 유효기간이 설정돼 있다. 이 유효기간이 지나면 로컬 스토리지에 저장해 둔 토큰을 지우고 사용자를 로그아웃시키도록 해야 한다. 이를 위해서 로컬 스토리지에 토큰을 저장할 때 유효기간을 계산해 함께 저장하고, 토큰을 불러올 때마다 현재 시각에서 유효기간까지의 남은 시간을 계산해서 토큰의 유효성을 체크한다.
//auth.js
//토큰 만료까지의 남은 시간을 계산한다.
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;
}
//루트 경로의 로더에 저장할 loader 함수
export function tokenLoader() {
return getAuthToken();
}
//app.js
export async function action({ request }) {
//(중략)
//토큰을 받아서 로컬 스토리지에 저장
const resData = await response.json();
const token = resData.token;
//토큰과 만료 시각을 함께 등록한다.
localStorage.setItem("token", token);
const expiration = new Date();
expiration.setHours(expiration.getHours() + 1);
localStorage.setItem("expiration", expiration.toISOString());
}
이렇게 리액트에 인증을 추가하는 작업을 해 보았다. 일상적으로 사용하는 로그인과 로그아웃 기능을 구현하기 위해서 어떤 작업이 필요한지 알게 되어 아주 유익했다. 앞으로 프로젝트를 하거나 실제로 일을 할 때 자주 다뤄야 할 기능이니 제대로 익힐 수 있도록 반복 학습!! 해야겠다.
(내가 보려고 정리하는) 참고 자료
URL과 URLSearchParams
https://www.zerocho.com/category/HTML&DOM/post/5b3ae84fb3dabd001b53b9ab
'React' 카테고리의 다른 글
[React] 리액트 버전 18의 주요 특징들 (0) | 2023.10.10 |
---|---|
client-side routing (0) | 2023.10.09 |
[React] 리액트 라우터 data api 사용하기 (0) | 2023.04.26 |
[Redux] 비동기 작업 처리하기(useEffect, thunk) (0) | 2023.04.21 |
[React] 리액트 성능 최적화: React.Memo, useCallback, useMemo (0) | 2023.04.18 |