User Authentication
사용자 인증의 원리
- 사용자가 로그인 요청을 백엔드에 보낸다.
- 그러면 서버는 어떻게 응답을 해야 하는가?
- 단순히 응답에
yes
라고 대답하면 안된다. 가짜 응답으로 정보를 탈취할 수 있기 때문이다.
- 단순히 응답에
- 보통 다음에 저술될 두 가지 방법을 사용한다.
서버 사이드 세션(Server-side Sessions)
- 프론트엔드와 백엔드가 분리되지 않은 풀스택 앱에서 자주 사용하는 대중적인 방법이다.
- 클라이언트가 로그인하고 인증된, 서버에 고유 식별자를 저장한다.
- 기본적으로
yes
를 서버에 저장하고, 식별자를 이용해, 그 대답을 특정 클라이언트와 연결한다. - 그리고 그 식별자를 다시 클라이언트에 보낸다.
- 그 이후, 클라이언트가 요청을 통해 보호되어 있는 리소스에 접근할 때, 해당 식별자를 전송한다.
- 서버는 이 클라이언트에 그 리소스에 접근할 권한이 있는지 이 식별자를 통해 확인한다.
이 방법은 백엔드가 클라이언트 관련 정보를 반드시 저장해야 하기 때문에, 백엔드와 프론트엔드 사이의 긴밀한 결합이 필요하다.
그러나 리액트에서는 보통 백엔드와 분리되어 있기 때문에, 적절하진 않다.
인증 토큰(Authentication Tokens)
- 클라이언트가 서버에 유효한 자격 증명을 전송한다. 그 다음에 서버에서 허가 토큰(permission token)을 생성한다.
- 이 허가 토큰은 기본적으로 생성되었을 때, 어느 저장 공간에도 저장되지 않는다.
- 이 토큰은 또한, 알고리즘에 따라 생성된 스트링으로 몇 가지 정보를 포함한다.
- 백엔드에서는 이 토큰을 생성하고, 클라이언트에게 전송한다.
- 이 토큰을 생성한 백엔드만 해당 토큰의 유효성을 확인하고 검증할 수 있다.(백엔드만 알 수 있는 개인 키 사용)
- 이후, 클라이언트에서 백엔드에 요청을 보낼 때, 해당 토큰을 첨부하면 백엔드에서 검증하고, 보호된 리소스에 대한 접근을 승인한다.
결국 서버는 클라이언트가 로그인을 하면,
yes
같은 응답이 아니라, 인증 토큰을 포함한 응답을 회신한다.
클라이언트 측 리액트에서는 이 토큰을 저장하고, 이후에 보낼 요청에서 해당 토큰을 통해 사용자의 로그인 요청을 판단하는 인디케이터로 사용한다.
쿼리 매개변수
- 쿼리 매개변수, 즉 검색 매개변수는 URL에서
?
뒤에 붙는 매개 변수이다. - ID나 이름, 또는 특정 데이터를 조회할 때에 사용된다.
- 리액트 라우터에서 쿼리 매개변수에 쉽게 접근할 수 있는
useSearchParams()
훅을 사용한다. useSearchParams
를 호출하면 배열을 반환하는데, 이 배열은 두 가지 요소가 있다.- 현재 설정된 쿼리 매개변수에 접근권을 주는 객체
- 현재 설정된 쿼리 매개변수를 업데이트하는 함수
const [searchParamas, setSearchParams] = useSearchParams();
const isLogin = searchParamas.get('mode') === 'login';
get
메서드를 통해 가져오고 싶은 쿼리 매개변수의 값을 가져온다.
action에서 쿼리 매개변수 추출
action
은 컴포넌트가 아니기 때문에 리액트 훅을 사용할 수 없다.- 따라서, 브라우저가 제공하는 내장 URL 생성자 함수를 사용한다.
const searchParamas = new URL(request.url).searchParams;
const mode = searchParamas.get('mode') || 'login';
- 사용자 인증 폼을 통한 액션은 다음과 같다.
export async function action({ request, params }) {
const searchParamas = new URL(request.url).searchParams;
const mode = searchParamas.get('mode') || 'login';
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: 'Could not authenticate user.',
},
{
status: 500,
},
);
}
return redirect('/');
}
사용자 관련 유효성 검증 오류 확인
useActionData
를 통해action
에서 전달된 데이터를 받는다.- 또한,
useNavigation
을 통해 제출 중인지 상태 파악을 한다. - JS의
Object.values()
를 통해 객체의 값들만 뽑아내는 것은 덤.
코드 보기
import {
Form,
Link,
useActionData,
useNavigation,
useSearchParams,
} from 'react-router-dom';
import classes from './AuthForm.module.css';
function AuthForm() {
const data = useActionData();
const navigation = useNavigation();
const [searchParamas, setSearchParams] = useSearchParams();
const isLogin = searchParamas.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>
)}
{data && data.errors && <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}>
<Link to={`?mode=${isLogin ? 'signup' : 'login'}`}>
{isLogin ? 'Create new user' : 'Login'}
</Link>
<button disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Save'}
</button>
</div>
</Form>
);
}
export default AuthForm;
백엔드 요청에 토큰 추가하기
action
에서 작업하는 코드는 브라우저에서 구동되기 때문에, 값들을 저장할 때 브라우저 API를 사용하여 로컬 저장소를 사용하여 저장한다.
const resData = await response.json();
const { token } = resData;
localStorage.setItem('token', token);
- 따로 파일을 만들어서 함수를 만들어도 된다.
export function getAuthToken() {
const token = localStorage.getItem('token');
return token;
}
- 백엔드에 토큰을 첨부해서 요청을 보낼려면 헤더에
authorization
을 추가한다.(키) - 토큰 앞에
Bearer
가 붙는데, 이것은 JWT와 OAuth를 나타내는 인증 타입이다.(참고자료)
export async function action({ params, request }) {
const { eventId } = params;
const token = getAuthToken();
const response = await fetch(`http://localhost:8080/events/${eventId}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw json(
{ message: 'Could not delete event.' },
{
status: 500,
},
);
}
return redirect('/events');
}
로그아웃
- 리액트 라우터가 제공하는
action
을 사용하기 위해 로그아웃 페이지를 따로 만든다. - 하지만 이 페이지는 빈 페이지에, 아무것도 렌더링하지 않는다. 단순히
action
만 존재한다. - 이
action
에서localStorage
에 접근해서 토큰을 삭제한다. - 로그아웃이 진행된 이후에, 권한이 필요하면 로그인 페이지로 리다이렉트하고, 권한이 필요없으면 그대로 새로고침하는 방식도 있다.
import { redirect } from 'react-router-dom';
export function action() {
localStorage.removeItem('token');
return redirect('/');
}
- 라우트 구조는 이런 방식으로 추가하면 된다.
- 리액트 라우터에서 제공하는
Form
을 이용하여 다른 페이지의action
을 가져올 수 있기 때문에,logout
페이지의action
을 가져온다.
<li>
<Form action="/logout" method="POST">
<button>Logout</button>
</Form>
</li>
로그인 상태에 따른 UI 업데이트
- 토큰이 삭제되면, 컴포넌트도 재평가되어야 하기 때문에 리액트 컨텍스트를 통해 앱 전반에 걸쳐 관리를 할 수도 있다.
- 또는, 리액트 라우터의
loader
를 이용한다.
구조 보기
import { NavLink, Form, 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}>
<li>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
end
>
Home
</NavLink>
</li>
<li>
<NavLink
to="/events"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
>
Events
</NavLink>
</li>
<li>
<NavLink
to="/newsletter"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
>
Newsletter
</NavLink>
</li>
{!token ? (
<li>
<NavLink
to="/auth?mode=login"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
>
Authentication
</NavLink>
</li>
) : (
<li>
<Form action="/logout" method="POST">
<button>Logout</button>
</Form>
</li>
)}
</ul>
</nav>
<NewsletterSignup />
</header>
);
}
export default MainNavigation;
라우트 보호 추가하기
- URL로 페이지를 접속하면, 접근할 수 있는 것을 방지하기 위해서 라우트 보호가 필요하다.
loader
를 활용해서 라우트를 보호한다.- 토근 유무를 파악해서, 토근이 없으면
redirect
를 한다.
export function checkAuthLoader() {
const token = getAuthToken();
if (!token) {
return redirect('/auth');
}
return null;
}
loader
를 이런 식으로 적용한다.
{
path: 'edit',
element: <EditEventPage />,
loader: checkAuthLoader,
action: manipulateEventAction,
},
자동 로그아웃
- 토큰이 만료되면 클라이언트를 로그아웃시키고, 토큰 또한 지운다.
- 루트 페이지에서
useEffect
를 통해서 토큰 만료를 확인한다.(만약 루트 페이지가 형제가 있으면 다른 방법을 사용한다.) - 실질적인 토큰 만료를 위해서 토큰 저장하는 곳에서 유효 시간도 같이 저장해야 한다.
- 자바스크립트의 날짜 객체를 만들고,
toISOString()
을 통해 해당 날짜 객체를 표준화된 스트링으로 변환한다. - 그 다음에 토큰 만료 날짜 객체를 로컬 저장소에 넣는다.
- 토큰 만들기 코드는 다음과 같다.
const { token } = resData;
localStorage.setItem('token', token);
const expiration = new Date();
expiration.setHours(expiration.getHours() + 1);
localStorage.setItem('expiration', expiration.toISOString());
export function getTokenDuration() {
const storedExpiration = localStorage.getItem('expiration');
const expirationData = new Date(storedExpiration);
const now = new Date();
const duration = expirationData.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;
}
- 그리고 루트 페이지에서 토큰을 관리하는 방식은 다음과 같다.
submit
을 통해 다른 페이지인 로그아웃 페이지의 액션을 이용한다.
const token = useLoaderData();
const submit = useSubmit();
useEffect(() => {
if (!token) {
return;
}
if (token === 'EXPIRED') {
submit(null, {
action: '/logout',
method: 'POST',
});
}
const tokenDuration = getTokenDuration();
setTimeout(() => {
submit(null, {
action: '/logout',
method: 'POST',
});
}, tokenDuration);
}, [token, submit]);
'React 완벽 가이드' 카테고리의 다른 글
React Query/Tanstack Query (0) | 2024.04.01 |
---|---|
Deploying React Apps (0) | 2024.04.01 |
A Multi-Page SPA with React Router (0) | 2024.03.29 |
Advanced Redux (2) | 2024.03.29 |
React Redux (0) | 2024.03.29 |