[React]인증

정호·2023년 7월 18일

React

목록 보기
1/30

  1. 단순한 응답의 한계: "왜 '네'라고만 하면 안 될까?"
    서버가 "로그인 성공!"이라는 단순한 텍스트만 보낸다면, 누군가 그 응답을 조작해서 서버에 보낼 수 있습니다. 서버는 이 요청이 진짜 나로부터 온 것인지, 아니면 조작된 것인지 구별할 방법이 없기 때문이죠. 그래서 "서버가 발행했음을 증명할 수 있는 증거물"이 필요합니다.

  2. 세션(Session) vs 토큰(Token)
    인증에는 크게 두 가지 대중적인 방법이 있습니다.

서버 측 세션 (Session): 서버가 "누가 로그인했는지"를 메모리에 기억하고 있는 방식입니다. 주로 프론트와 백엔드가 하나로 합쳐진 풀스택 앱에서 쓰입니다. 하지만 리액트처럼 서버와 클라이언트가 분리된 구조에서는 서버가 클라이언트의 상태를 저장하지 않는(Stateless) 방식이 더 효율적입니다.

인증 토큰 (Token - JWT): 서버는 로그인 성공 시 암호화된 문자열(토큰)을 만들어 클라이언트에게 던져줍니다. 서버는 이 정보를 따로 저장하지 않습니다. 나중에 클라이언트가 이 토큰을 다시 들고 오면, 서버는 자기가 가진 비밀키(Private Key)로 "내가 만든 게 맞나?"만 확인합니다.

  1. JWT(JSON Web Token)의 마법
    강의에서 언급된 '가짜 백엔드'가 발행하는 것이 바로 JWT입니다.

생성: 백엔드만 아는 비밀키를 사용해 알고리즘으로 생성합니다.

검증: 클라이언트가 요청을 보낼 때 토큰을 함께 보내면, 백엔드의 미들웨어(Middleware)가 비밀키를 대조해 유효성을 검사합니다.

보안: 비밀키가 노출되지 않는 한, 외부에서 토큰을 위조하는 것은 거의 불가능합니다.


  1. useSearchParams: URL로 상태 관리하기
    가장 중요한 포인트는 useState 대신 useSearchParams를 사용해 현재 페이지가 '로그인' 모드인지 '회원가입' 모드인지 결정한다는 점입니다.

searchParams.get('mode'): URL 뒤에 붙는 ?mode=login 같은 쿼리 스트링을 읽어옵니다.

장점: 사용자가 페이지를 새로고침해도 로그인/회원가입 폼 상태가 유지되고, '뒤로 가기'를 눌러 이전 모드로 돌아갈 수도 있습니다.

  1. Link를 이용한 모드 전환 (쿼리 스트링)
    버튼을 눌러 로그인과 회원가입 폼을 전환할 때, 별도의 함수 없이 태그 하나로 처리하고 있습니다.

<Link to={`?mode=${isLogin ? 'signup' : 'login'}`}>
  {isLogin ? 'Create new user' : 'Login'}
</Link>
  

상대 경로: to 속성에 ?mode=...처럼 물음표로 시작하는 값을 주면, 현재 주소는 유지한 채 쿼리 스트링만 싹 바꿔줍니다.

  1. : 인증 데이터 전송 준비 이미 앞에서 배운 리액트 라우터의 이 여기서도 쓰입니다.

method="post": 인증 정보(이메일, 비밀번호)는 보안이 중요하므로 URL에 노출되지 않도록 post 방식을 사용합니다.

name 속성: input 태그의 name="email", name="password"는 나중에 이 라우트에 연결될 action 함수에서 데이터를 추출할 때 열쇠가 됩니다.


토큰 ui변경

  • loader

  • root에 tokenLoader


토큰 만료

1단계: 로그인 (Authentication.js)
사용자가 ID/PW를 입력하고 제출했을 때 시작됩니다.

데이터 전송: action 함수가 실행되어 백엔드로 정보를 쏩니다.

토큰 수령: 서버가 "맞네!" 하고 토큰을 보내줍니다.

저장 (핵심!):

localStorage.setItem('token', token)으로 신분증 보관.

new Date()를 써서 "지금부터 1시간 뒤"라는 만료 시각을 계산해 expiration 이름으로 저장합니다.

이동: redirect('/')를 통해 홈으로 보냅니다.

2단계: 앱 초기화 및 상태 유지 (auth.js & App.js)
페이지를 새로고침하거나 다른 메뉴로 이동할 때 발생합니다.

루트 로더 실행: App.js에 설정된 tokenLoader가 돌아갑니다.

만료 체크 (getAuthToken):

auth.js의 getAuthToken이 호출됩니다.

여기서 getTokenDuration()을 써서 [저장된 만료 시각 - 현재 시각]을 계산합니다.

시간이 남았으면 토큰을 주고, 지났으면 'EXPIRED'를 던집니다.

UI 업데이트: MainNavigation 같은 곳에서 이 토큰 유무에 따라 '로그아웃 버튼'을 보여줄지 결정합니다.

3단계: 자동 로그아웃 감시 (Root.js)
앱이 켜져 있는 동안 RootLayout 컴포넌트가 계속 감시합니다.

Effect 실행: useEffect가 현재 토큰 상태를 봅니다.

타이머 설정:

getTokenDuration()으로 진짜 남은 시간이 얼마인지 계산합니다. (예: 40분 남음)

setTimeout을 그 시간만큼 걸어둡니다.

강제 제출: 시간이 다 되면 useSubmit을 통해 /logout 경로로 '로그아웃 해!'라는 요청을 자동으로 보냅니다.

4단계: 로그아웃 처리 (Logout.js)
사용자가 직접 눌렀든, 자동 타이머가 작동했든 상관없이 마지막은 여기서 끝납니다.

청소: 로컬 스토리지에서 token과 expiration을 싹 지웁니다.

완료: 다시 홈 페이지('/')로 리디렉션하며 끝납니다.

//Root.js
function RootLayout() {
const token = useLoaderData();
const submit = useSubmit();
// const navigation = useNavigation();
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]);

//auth.js
import { redirect } from 'react-router-dom';

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;
}

export function tokenLoader() {
const token = getAuthToken();
return token;
}

export function checkAuthLoader() {
const token = getAuthToken();

if (!token) {
  return redirect('/auth');
}
}

                      AuthenticationPage.js
import { json, redirect } from 'react-router-dom';

import AuthForm from '../components/AuthForm';

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

export default AuthenticationPage;

export async function action({ request }) {
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 });
}

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 });
}

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());

return redirect('/');
}
          
                      
profile
열심히 기록할 예정🙃

0개의 댓글