Django와 React.js에서 로그인 기능과 회원가입 기능을 구현하는 방법입니다.
원문
위 글을 번역하고 필요한 내용은 추가하면서 만들었습니다.
Frontend 부분의 인증 과정을 구현하려고 합니다. pages, components, custom hooks, utility function와 route guard를 사용한 routes를 사용합니다.
목차
npm install -global yarn
yarn create react-app .
.
은 현재 폴더에 프로젝트를 만들겠다라는 의미입니다.yarn add axios dayjs jwt-decode react-router-dom
yarn start
사용자 정보를 저장해서 모든 어플리케이션(페이지, 컴포넌트등)에서 접근해서 사용할 수 있어야합니다. 보통 store라면 Redux를 사용해서 구현하는 것을 떠올리지만 여기서는 Redux없이 React hook인 useContext
를 사용해서 구현합니다.
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를 초기화 시켜줍니다.
authTokens
와 loading
의 상태가 변경되었을 때, 사용자 정보는 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에 사용자 이름하고 로그아웃 버튼이 생긴 것을 볼 수 있습니다.
전체 코드는 아래 링크에서 확인할 수 있습니다.
링크