Next.js 쿠키와 JWT 토큰으로 로그인 구현
Next.js로 웹 앱의 인증 기능을 구현하면서 MongoDB, Next API Routes와 Next Auth라는 Hooks로 인증 상태관리가 편리한 라이브러리를 사용했습니다.
하지만 이것만으로는 모든 상황의 인증 로직 구현을 이해했다 보기 어려워 jwt 토큰과 쿠키를 쓰는 코드를 따로 구현해보았습니다.
쿠키를 도입한 이유는 local storage,session storage가 취약한 csrf 공격은 프론트엔드 개발자 입장에서 막기 힘들지만 xss 공격에 취약한 쿠키는 httpOnly: true 옵션과 access token, refresh token 활용으로 보안상의 이점을 더 누릴 수 있다는 의견이 커뮤니티에서 더 많아 사용하게 되었습니다. 이 글에서는 access token, refresh token의 활용까지 다루지는 않습니다.
오늘 쓰는 주요 라이브러리, 프레임워크는 아래와 같습니다.
- Next.js (13 버전은 아직 stable 출시가 안된 상태이며 실제로 Mutation 구현 같이 불안정하다 느낀 부분도 있어서 12를 사용했습니다.)
- Tailwind CSS (클래스 문법으로 간단하고 빠르게 스타일을 구현하게 해줍니다. 처음 쓸땐 매우 길어지는 클래스명 때문에 적응이 안되서 불편했지만 써볼수록 정말 빠른 속도로 개발이 가능해서 사용하게 되었습니다.)
- @tanstack/react-query (Redux나 React Context가 클라이언트 상태 관리를 해준다면 react-query는 캐싱과 같이 서버의 상태를 관리해줍니다. 더 이상 서버 상태 관리를 위해 복잡한 Redux Saga같은 미들웨어에 의존하지 않아도 됩니다.)
- Formik (이 글에서는 yup과 schema validation을 사용하지 않기 때문에 기본 HTML Form으로 하셔도 무방합니다.)
- Cookies-next (꼭 이 라이브러리를 사용할 필요는 없습니다. 어차피 react-cookies나 js-cookie 같은 유명한 라이브러리들의 코드도 비슷하고 그려지는 큰 그림 역시 같습니다.)
- React Hot Toast (이벤트나 요청의 결과를 출력하기 위해 사용합니다. 적당한 크기의 토스트 모양으로 알림을 보내줍니다.)
- Strapi (Headless CMS이며 이 프로젝트의 백엔드를 담당합니다. 클라이언트에서 인증 요청을 보내면 유저 정보와 jwt토큰을 반환합니다. 인증 외에 글 쓰기, 목록, 유저 관리, AWS S3에 이미지 업로드, GraphQL 지원 등 강력한 기능들도 많습니다. 본인이 호스팅 할 환경만 된다면 백엔드 코드를 하나도 몰라도 Strapi 서버와 쉽게 데이터를 주고 받을 수 있습니다. localhost:8080 형식으로 개발 서버 역시 지원합니다. 저는 주로 프론트엔드 코드에 집중하고 싶을 때 사용합니다. Strapi의 사용법은 https://docs.strapi.io/developer-docs/latest/getting-started/quick-start.html 를 참고해주세요.)
프로젝트 준비하기
yarn create next-app --typescript
cd (설치한 프로젝트 폴더명)
yarn add @tanstack/react-query axios cookies-next formik react-hot-toast
yarn add -D tailwindcss postcss autoprefixer // tailwind css는 개발 전용으로 설치
yarn tailwindcss init -p
yarn 패키지 매니저로 라이브러리들을 설치해줍니다. 이제 tailwind css 관련 설정을 해보겠습니다.
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
"./common/**/*.{js,ts,jsx,tsx}",
"./features/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
animation: {},
keyframes: {},
},
},
plugins: [],
};
명령어 입력 후, 생성된 tailwind.config.js 파일을 위와 같이 수정해주세요. content 안에 들어간 문자열들로 tailwind css를 적용할 경로를 지정해주었습니다.
// styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
마지막으로 @tailwind directives를 styles/globals.css에 설정해주면 tailwind css 설정은 끝입니다.
// lib/client.ts
import axios from "axios";
const client = axios.create({
baseURL: "http://localhost:1337",
});
export default client;
axios crate를 활용하여 저장된 백엔드 URL을 재사용하겠습니다.
// lib/query-client.ts
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// background error가 발생했을 때 실행됩니다.
if (query.state.data !== undefined) {
toast.error(`에러가 발생하였습니다: ${error}`);
}
},
}),
});
next.js _app 파일에 사용할 react query의 client를 생성하겠습니다. 에러가 발생하면 react-hot-toast에서 에러문을 출력해줍니다.
// pages/_app.tsx
import "../styles/globals.css";
import { QueryClientProvider } from "@tanstack/react-query";
import type { AppProps } from "next/app";
import { queryClient } from "../lib/query-client";
import Layout from "../features/Layout";
function MyApp({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<Layout>
<Component {...pageProps} />
</Layout>
</QueryClientProvider>
);
}
export default MyApp;
Layout 컴포넌트는 지금 상황에서 신경쓰지 않으셔도 됩니다.
// types/auth.ts
export interface User {
id: string;
username: string;
email: string;
provider: string;
createdAt: Date;
updatedAt: Date;
confirmed: boolean;
blocked: boolean;
}
export interface RegisterResult {
user: User;
jwt: string;
}
export interface RegisterParams {
email: string;
password: string;
username: string;
}
export interface LoginParams {
identifier: string;
password: string;
}
// types/error.ts
import { AxiosError } from "axios";
type ErrorData = {
message: {
id: string;
message: string;
}[];
}[];
export type Error = AxiosError<{
statusCode: number;
error: string;
message: ErrorData;
data: ErrorData;
}>;
이번 프로젝트에 사용할 Typescript Type 파일들을 작성하였습니다. 필요할 때 작성해도 되는 파일들이니 지금 당장 작성하지 않으셔도 됩니다.
이제 본격적으로 기능 구현을 해보겠습니다.
회원가입 기능 구현
// directory: features/Auth/RegisterForm.tsx
import { useFormik } from "formik";
import { useRouter } from "next/router";
import { useRegister } from "./Queries";
const RegisterForm: React.FunctionComponent = () => {
const router = useRouter();
const switchAuthModeHandler = () => router.push("/auth/login");
const { mutate: register, isLoading } = useRegister();
const formik = useFormik({
initialValues: {
email: "",
password: "",
username: "",
},
onSubmit: async (values) => {
const enteredEmail: string = values.email;
const enteredPassword: string = values.password;
const enteredUsername: string = values.username;
if (isLoading) {
return;
}
register(
{
email: enteredEmail,
password: enteredPassword,
username: enteredUsername,
},
{
onSuccess: () => {
router.push("/auth/login");
},
}
);
},
});
return (
<>
<div className="mt-16">
<div className="flex justify-center items-center">
<div className="w-[450px] min-h-[500px] px-8 py-5">
<h3 className="text-2xl font-medium text-gray-200 text-center">
회원가입
</h3>
<form onSubmit={formik.handleSubmit}>
<div className="mt-7">
<input
type="email"
name="email"
placeholder="이메일"
value={formik.values.email}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="mt-7">
<input
type="password"
name="password"
placeholder="비밀번호"
value={formik.values.password}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="mt-7">
<input
type="text"
name="username"
placeholder="닉네임"
value={formik.values.username}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="flex mt-12 justify-between items-center">
<button type="button" onClick={switchAuthModeHandler}>
로그인
</button>
<button type="submit" className="btn-primary">
회원가입
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
};
export default RegisterForm;
formik에서 onsubmit이 발생 시 react query의 mutate를 실행해서 form에 입력된 데이터를 기반으로 서버에 회원가입 요청을 보냅니다. onSuccess가 발생하면 login 페이지로 이동합니다. 밑에 코드에서 useRegister()를 살펴보겠습니다.
// features/Auth/Queries.tsx
import { useMutation } from "@tanstack/react-query";
import client from "../../lib/client";
import { setJwtToken } from "../../lib/storage";
import { RegisterParams, RegisterResult } from "../../types/auth";
import { Error } from "../../types/error";
export async function register(params: RegisterParams) {
const { data } = await client.post<RegisterResult>(
"/api/auth/local/register",
params
);
return data;
}
export function useRegister() {
const mutation = useMutation(register, {
onSuccess: (data) => {
console.log("User profile", data.user);
console.log("User token", data.jwt);
},
onError: (error: Error) => {
console.log("An error occurred:", error.response);
},
});
return mutation;
}
react query가 입력했던 폼의 데이터로 백엔드에 요청을 보내고 결과를 받아옵니다.
onSuccess가 발생하면 유저의 데이터와 토큰을 받아옵니다.
로그인 기능 구현
// features/Auth/LoginForm.tsx
import { useFormik } from "formik";
import { useRouter } from "next/router";
import { useLogin } from "./Queries";
const LoginForm: React.FunctionComponent = () => {
const router = useRouter();
const switchAuthModeHandler = () => router.push("/auth/register");
const { mutate: login, isLoading } = useLogin();
const formik = useFormik({
initialValues: {
identifier: "",
password: "",
},
onSubmit: async (values) => {
const enteredidentifier: string = values.identifier;
const enteredPassword: string = values.password;
if (isLoading) {
return;
}
login(
{
identifier: enteredidentifier,
password: enteredPassword,
},
{
onSuccess: () => {
router.push("/");
},
}
);
},
});
return (
<>
<div className="mt-16">
<div className="flex justify-center items-center">
<div className="w-[450px] min-h-[500px] px-8 py-5">
<h3 className="text-2xl font-medium text-gray-200 text-center">
로그인
</h3>
<form onSubmit={formik.handleSubmit}>
<div className="mt-7">
<input
type="text"
name="identifier"
placeholder="이메일 or 닉네임"
value={formik.values.identifier}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="mt-7">
<input
type="password"
name="password"
placeholder="비밀번호"
value={formik.values.password}
onChange={formik.handleChange}
className="w-full bg-gray-700 px-4 py-2 rounded text-slate-300 outline-0 border-transparent border-2 focus:border-gray-400"
/>
</div>
<div className="flex mt-12 justify-between items-center">
<button
className="text-sm"
type="button"
onClick={switchAuthModeHandler}
>
회원가입
</button>
<button type="submit" className="btn-primary">
로그인
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
};
export default LoginForm;
RegisterForm 코드와 유사합니다. 다른 점은 identifier라는 변수로 email이나 username을 ID 취급하여 확인합니다.
Formik을 사용하지 않으신다면 RegisterForm과 LoginForm을 하나의 파일 안에 작성하셔도 좋습니다.
기존의 Queries.tsx 파일을 수정해서 useLogin 함수를 작성해보겠습니다.
// features/auth/Queries.tsx
import { useMutation } from "@tanstack/react-query";
import client from "../../lib/client";
import { setJwtToken } from "../../lib/storage";
import { LoginParams, RegisterParams, RegisterResult } from "../../types/auth";
import { Error } from "../../types/error";
export async function register(params: RegisterParams) {
const { data } = await client.post<RegisterResult>(
"/api/auth/local/register",
params
);
return data;
}
export function useRegister() {
const mutation = useMutation(register, {
onSuccess: (data) => {
console.log("User profile", data.user);
console.log("User token", data.jwt);
},
onError: (error: Error) => {
console.log("An error occurred:", error.response);
},
});
return mutation;
}
export async function login(params: LoginParams) {
const { data } = await client.post<RegisterResult>("/api/auth/local", params);
return data;
}
export function useLogin() {
const mutation = useMutation(login, {
onSuccess: (data) => {
console.log("Well done!");
console.log("User profile", data.user);
console.log("User token", data.jwt);
setJwtToken(data.jwt);
setUsername(data.user.username);
},
onError: (error: Error) => {
console.log("An error occurred:", error.response);
},
});
return mutation;
}
Register 함수하고 크게 다른 점은 onSuccess 발생 시 setJwtToken 함수가 실행된다는 점입니다. 받아온 jwt 토큰을 쿠키에 저장하는 함수인데요 지금부터 작성해보겠습니다.
쿠키 기능 구현
// lib/storage.ts
import { getCookie, setCookie, deleteCookie, hasCookie } from "cookies-next";
export const setJwtToken = (jwtToken: string) => {
const today = new Date();
const expireDate = today.setDate(today.getDate() + 7);
return setCookie("jwt_token", jwtToken, {
sameSite: "strict",
path: "/",
expires: new Date(expireDate),
});
};
export const setUsername = (username: string) => {
const today = new Date();
const expireDate = today.setDate(today.getDate() + 7);
return setCookie("username", username, {
sameSite: "strict",
path: "/",
expires: new Date(expireDate),
});
};
export const getCookieToken = () => {
return getCookie("jwt_token");
};
export const removeCookieToken = () => {
return [
deleteCookie("jwt_token", { sameSite: "strict", path: "/" }),
deleteCookie("username", { sameSite: "strict", path: "/" }),
];
};
export const isCookieToken = () => {
return hasCookie("jwt_token");
};
setJwtToken 함수가 리턴하는 setCookie의 매개변수는 key("jwt_token"), value(jwtToken <- 문자열), Options(객체)를 받습니다. Options의 path에 "/"를 주면 이 사이트 전역에 쿠키를 사용할수 있습니다. 만료일은 7일로 설정하였습니다.
setUsername 역시 setJwtToken 함수와 비슷하게 동작합니다.
나머지 함수들은 get, remove로 이름과 같은 기능을 수행하며 isCookieToken은 현재 사용자의 "jwt_token" 쿠키 보유 여부를 boolean으로 리턴합니다.
결과
// pages/auth/login.tsx
import * as React from "react";
import LoginForm from "../../features/auth/LoginForm";
const Login: React.FunctionComponent = () => {
return (
<>
<LoginForm />
</>
);
};
export default Login;
// pages/auth/register.tsx
import * as React from "react";
import RegisterForm from "../../features/auth/RegisterForm";
const Register: React.FunctionComponent = () => {
return (
<>
<RegisterForm />
</>
);
};
export default Register;
Next.js pages 폴더에 작성했던 폼 컴포넌트를 입력하였습니다.
회원가입이 완료되면 로그인 페이지로 이동합니다. 그리고 백엔드의 대시보드에 회원가입한 유저가 생성된것을 확인 할 수 있습니다!
이제 가입했던 정보로 로그인을 해봅시다.
로그인이 완료되면 콘솔을 확인해봅시다.
console.log에 유저 정보들과 jwt 토큰이 출력됩니다.
또한 쿠키에 jwt_token과 username이 저장되었습니다.
이제 프로젝트 모든 파일에 적용되는 스타일인 Layout을 작성하고 Header 파일에 로그인 상태를 유지시켜봅시다.
// features/Layout/index.tsx
import * as React from "react";
import Header from "./Header";
interface ILayoutProps {
children: JSX.Element;
}
const Layout: React.FunctionComponent<ILayoutProps> = ({ children }) => {
return (
<>
<Header />
<main>{children}</main>
</>
);
};
export default Layout;
// pages/_app.tsx
import "../styles/globals.css";
import { QueryClientProvider } from "@tanstack/react-query";
import type { AppProps } from "next/app";
import { queryClient } from "../lib/query-client";
import store from "../lib/store";
import Layout from "../features/Layout";
function MyApp({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<Layout>
<Component {...pageProps} />
</Layout>
</QueryClientProvider>
);
}
export default MyApp;
// features/Layout/Header.tsx
import Link from "next/link";
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import {
getCookieToken,
isCookieToken,
removeCookieToken,
} from "../../lib/storage";
const Header: React.FunctionComponent = () => {
const router = useRouter();
const [isAuth, setAuth] = useState(false);
const token = getCookieToken();
const handleLoginClick = () => {
router.push("/auth/login");
};
const handleLogOutClick = () => {
window.location.reload();
removeCookieToken();
};
useEffect(() => {
setAuth(isCookieToken);
}, [token]);
return (
<>
<header className="flex justify-center w-full text-gray-200 min-h-15 top-0 z-10 px-12">
<nav className="flex justify-between mt-3 items-center relative w-full z-12">
<div className="flex items-center">
<Link href="/">Training App</Link>
</div>
<div className="flex items-center">
<ol className="flex justify-between items-center p-0 m-0 list-none">
<li className="mx-[5px] relative text-base">
{isAuth ? (
<div className="cursor-pointer" onClick={handleLogOutClick}>
로그아웃
</div>
) : (
<div className="cursor-pointer" onClick={handleLoginClick}>
로그인
</div>
)}
</li>
</ol>
</div>
</nav>
</header>
</>
);
};
export default Header;
헤더에서 쿠키 값을 확인하여 있다면 로그아웃 링크를 출력하고 없다면 로그인 링크를 출력합니다.
로그아웃을 클릭하면 브라우저에 저장된 쿠키가 삭제됩니다.
이번 글은 여기까지입니다. 긴 글 읽어주셔서 감사합니다.