React.js + Django 로그인 구현 2

DARTZ·2023년 4월 24일
2

Projects

목록 보기
2/5

서론

Django와 React.js에서 로그인 기능과 회원가입 기능을 구현하는 방법입니다.
원문
위 글을 번역하고 필요한 내용은 추가하면서 만들었습니다.

본문

Frontend 부분의 인증 과정을 구현하려고 합니다. pages, components, custom hooks, utility function와 route guard를 사용한 routes를 사용합니다.

Backend 파트 참고

목차

  • 필요항목들
  • 프로젝트 설정(Frontend)
  • 프로젝트 시작
  • App 테스트

필요항목들

  • node.js를 설치해야합니다.
  • yarn을 사용해서 패키지를 설치하겠습니다. npm install -global yarn
  • 나머지는 Backend part를 참고

프로젝트 설정 (Frontend)

  1. FrontEnd 폴더로 이동하세요.
  2. 리액트 프로젝트를 생성하세요.
    yarn create react-app .
    끝에 .은 현재 폴더에 프로젝트를 만들겠다라는 의미입니다.
  3. 필요 패키지 설치
    yarn add axios dayjs jwt-decode react-router-dom
  4. 시작
    yarn start

프로젝트 시작

사용자 정보를 저장해서 모든 어플리케이션(페이지, 컴포넌트등)에서 접근해서 사용할 수 있어야합니다. 보통 store라면 Redux를 사용해서 구현하는 것을 떠올리지만 여기서는 Redux없이 React hook인 useContext를 사용해서 구현합니다.

  • src폴더에서 context폴더를 만들고 AuthContext라는 파일을 만들어줍니다.
import { createContext, useState, useEffect } from "react";
import jwt_decode from "jwt-decode";
import { useNavigate } from "react-router-dom";

const AuthContext = createContext(); // Context 생성

export default AuthContext;

export const AuthProvider = ({ children }) => {
  const [authTokens, setAuthTokens] = useState(() =>
    localStorage.getItem("authTokens")
      ? JSON.parse(localStorage.getItem("authTokens"))
      : null
  ); // localStorage에 authTokens이 있을 경우 가져와서 authTokens에 넣는다.
  const [user, setUser] = useState(() =>
    localStorage.getItem("authTokens")
      ? jwt_decode(localStorage.getItem("authTokens"))
      : null
  ); // localStorage에 authTokens이 있을 경우 jwt_decode로 authTokens를 decode해서 user 정보에 넣는다.
  const [loading, setLoading] = useState(true);

  const navigate = useNavigate(); // react router dom 6버전 이상부터는 useHistory대신 useNavigate 사용

  const loginUser = async (username, password) => {
    const response = await fetch("http://127.0.0.1:8000/api/token/", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        username,
        password
      })
    });
    const data = await response.json();

    // 로그인에 성공했을 경우 홈으로 이동
    if (response.status === 200) {
      setAuthTokens(data);
      setUser(jwt_decode(data.access));
      localStorage.setItem("authTokens", JSON.stringify(data));
      navigate("/");
    } else {
      alert("Something went wrong!");
    }
  };
  
  const registerUser = async (username, password, password2) => {
    const response = await fetch("http://127.0.0.1:8000/api/register/", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        username,
        password,
        password2
      })
    });
    if (response.status === 201) {
      navigate("/login");
    } else {
      alert("Something went wrong!");
    }
  };

  const logoutUser = () => {
    setAuthTokens(null);
    setUser(null);
    localStorage.removeItem("authTokens");
    navigate("/");
  };

  const contextData = {
    user,
    setUser,
    authTokens,
    setAuthTokens,
    registerUser,
    loginUser,
    logoutUser
  };

  useEffect(() => {
    if (authTokens) {
      setUser(jwt_decode(authTokens.access));
    }
    setLoading(false);
  }, [authTokens, loading]);

코드를 살펴보면
AuthContext를 만들었고, src 폴더 어느 곳에서든지 import해서 사용가능합니다. 여기로 접근해서 contextData 사용이 가능합니다. 기본적으로 모든 app들을 AuthProvider로 감쌀 것입니다.
loginUser - username과 password가 필요합니다. 만약에 사용자가 데이터 베이스(인증되어있는 경우)에 있는 경우 로그인되고 access와 refresh토큰이 local storage에 저장될 것입니다.
registerUser - username, password, password2가 필요합니다. 이 함수를 실행하면 데이터베이스에 유저 정보를 저장해줄 것입니다. Unique한 username하고 password와 password가 일치해야합니다. 만약 회원가입에 성공하면 로그인 페이지에 redirect 시켜줍니다.
logoutUser - 간단하게 로그아웃시켜고 local storage를 초기화 시켜줍니다.
authTokensloading의 상태가 변경되었을 때, 사용자 정보는 UseEffect의 영향을 받아서 바뀝니다. dwt_decode는 access token을 decode 해줍니다.

문제 - Access token의 수명은 보통 매우 짧습니다. 그래서 사용자는 적은 시간만 유효할 것입니다. 그리고 token이 만료되어 버리면 사용자는 private routes에 더이상 접근할 수 없게 됩니다.

해결접근 - 이 문제를 풀기 위해서 server로 가기전에 요청을 가로채야합니다. 요청을 가로채서 우리는 token이 유효한지 아닌지 살펴봐야 합니다. 만약 유효하지 않다면 refresh를 통해 새로운 access 토큰을 발급하여 보내줘야합니다.

구현 - 구현을 위해 axios를 사용할 것입니다. axios에 interceptors를 사용합니다. 그러면 우리는 request(요청)을 가로챌 수 있습니다. 그래서 우리는 axios를 private API를 사용할 때에 사용할 것입니다.

import axios from "axios";
import jwt_decode from "jwt-decode";
import dayjs from "dayjs";
import { useContext } from "react";
import AuthContext from "../context/AuthContext";

const baseURL = "http://127.0.0.1:8000/api";

const useAxios = () => {
  const { authTokens, setUser, setAuthTokens } = useContext(AuthContext);

  const axiosInstance = axios.create({
    baseURL,
    headers: { Authorization: `Bearer ${authTokens?.access}` } 
  }); // 중요! Bearer 인증 방식을 알려주기 위해 'Bearer Token'형식으로 보내줘야합니다.

  axiosInstance.interceptors.request.use(async req => {
    const user = jwt_decode(authTokens.access);
    const isExpired = dayjs.unix(user.exp).diff(dayjs()) < 1; // 토큰만료 상태 체크

    if (!isExpired) return req; // 만료 안되면 access토큰 사용

    const response = await axios.post(`${baseURL}/token/refresh/`, {
      refresh: authTokens.refresh
    }); // 만료 되었을 경우 refresh토큰을 사용해서 access 토큰 재발급

    localStorage.setItem("authTokens", JSON.stringify(response.data));

    setAuthTokens(response.data);
    setUser(jwt_decode(response.data.access));

    req.headers.Authorization = `Bearer ${response.data.access}`;
    return req;
  });

  return axiosInstance;
};

잠시 살펴보자면

useContext를 통해서 authTokens, setUser, setAuthTokens를 사용 가능합니다.
확실한 인증을 통해 private routes를 사용하기 위해 axios instance를 만들었습니다.
access token을 decode 하면 만료 날짜를 알 수 있습니다. 만약 만료 되었다면 새로운 access token을 발급받게 됩니다.

지금까지 4개의 route를 가지고 있습니다.
/login
/register
/
/protected - private route

만약 사용자가 로그인 했을 경우, private route에 접속 가능합니다. 아니면 로그인 페이지로 보내질 것입니다. 이제 private route 컴포넌트를 작성해봅시다.

src폴더 안에 utils 폴더 안에PrivateRoute.js파일을 만들어주면 됩니다.

import { Navigate, Outlet } from "react-router-dom";
import { useContext } from "react";
import AuthContext from "../context/AuthContext";

const PrivateRoute = () => {
  let { user } = useContext(AuthContext);
  return !user ? <Navigate to="/login" /> : <Outlet />;
};

export default PrivateRoute;

사용자가 있는지 없는지 확인해서 login page로 보낼 것인지 아니면 private 라우트 안의 페이지를 보여줄 것인지를 결정해줍니다.

이제 page와 컴포넌트들을 만들어서 사용해봅시다.

// App.js
import React from "react";
import "./index.css";
import Footer from "./components/Footer";
import Navbar from "./components/Navbar";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import PrivateRoute from "./utils/PrivateRoute";
import { AuthProvider } from "./context/AuthContext";
import Home from "./views/homePage";
import Login from "./views/loginPage";
import Register from "./views/registerPage";
import ProtectedPage from "./views/ProtectedPage";

function App() {
  return (
    <Router>
      <div className="flex flex-col min-h-screen overflow-hidden">
        <AuthProvider>
          <Navbar />
          <Routes>
            <PrivateRoute component={ProtectedPage} path="/protected" exact />
            <Route component={Login} path="/login" />
            <Route component={Register} path="/register" />
            <Route component={Home} path="/" />
          </Routes>
        </AuthProvider>
        <Footer />
      </div>
    </Router>
  );
}

export default App;
// components/Footer.js
const Footer = () => {
  return (
    <div>
      <h4>Created By You</h4>
    </div>
  );
};

export default Footer;
// components/Navbar.js
import { useContext } from "react";
import { Link } from "react-router-dom";
import AuthContext from "../context/AuthContext";

const Navbar = () => {
  const { user, logoutUser } = useContext(AuthContext);
  return (
    <nav>
      <div>
        <h1>App Name</h1>
        <div>
          {user ? (
            <>
              <Link to="/">Home</Link>
              <Link to="/protected">Protected Page</Link>
              <button onClick={logoutUser}>Logout</button>
            </>
          ) : (
            <>
              <Link to="/login">Login</Link>
              <Link to="/register">Register</Link>
            </>
          )}
        </div>
      </div>
    </nav>
  );
};

export default Navbar;
// components/UserInfo.js
function UserInfo({ user }) {
  return (
    <div>
      <h1>Hello, {user.username}</h1>
    </div>
  );
}

export default UserInfo;
// utils/ProtectedPage.js
import { useEffect, useState } from "react";
import useAxios from "../utils/useAxios";

function ProtectedPage() {
  const [res, setRes] = useState("");
  const api = useAxios();

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await api.get("/test/");
        setRes(response.data.response);
      } catch {
        setRes("Something went wrong");
      }
    };
    fetchData();

  }, []);

  return (
    <div>
      <h1>Projected Page</h1>
      <p>{res}</p>
    </div>
  );
}

export default ProtectedPage;
// views/homePage.js
import { useContext } from "react";
import UserInfo from "../components/UserInfo";
import AuthContext from "../context/AuthContext";

const Home = () => {
  const { user } = useContext(AuthContext);
  return (
    <section>
      {user && <UserInfo user={user} />}
      <h1>You are on home page!</h1>
    </section>
  );
};

export default Home;
// views/loginPage.js
import { useContext } from "react";
import AuthContext from "../context/AuthContext";

const LoginPage = () => {
  const { loginUser } = useContext(AuthContext);
  const handleSubmit = e => {
    e.preventDefault();
    const username = e.target.username.value;
    const password = e.target.password.value;
    username.length > 0 && loginUser(username, password);
  };

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <h1>Login </h1>
        <hr />
        <label htmlFor="username">Username</label>
        <input type="text" id="username" placeholder="Enter Username" />
        <label htmlFor="password">Password</label>
        <input type="password" id="password" placeholder="Enter Password" />
        <button type="submit">Login</button>
      </form>
    </section>
  );
};

export default LoginPage;
// views/registerPage.js
import { useState, useContext } from "react";
import AuthContext from "../context/AuthContext";

function Register() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [password2, setPassword2] = useState("");
  const { registerUser } = useContext(AuthContext);

  const handleSubmit = async e => {
    e.preventDefault();
    registerUser(username, password, password2);
  };

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <h1>Register</h1>
        <hr />
        <div>
          <label htmlFor="username">Username</label>
          <input
            type="text"
            id="username"
            onChange={e => setUsername(e.target.value)}
            placeholder="Username"
            required
          />
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            onChange={e => setPassword(e.target.value)}
            placeholder="Password"
            required
          />
        </div>
        <div>
          <label htmlFor="confirm-password">Confirm Password</label>
          <input
            type="password"
            id="confirm-password"
            onChange={e => setPassword2(e.target.value)}
            placeholder="Confirm Password"
            required
          />
          <p>{password2 !== password ? "Passwords do not match" : ""}</p>
        </div>
        <button>Register</button>
      </form>
    </section>
  );
}

export default Register;

폴더 구조는 위와 같습니다!

테스트

이제 yarn start를 통해서 http://localhost:3000/ 로 접속합니다.

로그인 안했을 때, 사용자 화면입니다.

사용자 아이디와 비밀번호를 까먹었을 때에는 새로 회원가입하면 됩니다! 로그인에 성공하면 이제 Navbar에 사용자 이름하고 로그아웃 버튼이 생긴 것을 볼 수 있습니다.

전체 코드는 아래 링크에서 확인할 수 있습니다.
링크

profile
사람들이 비용을 지불하고 사용할 만큼 가치를 주는 서비스를 만들고 싶습니다.

0개의 댓글