Trong bài viết này, chúng ta sẽ xây dựng một dự án example bao gồm Đăng nhập & Đăng ký React.js với JWT & HttpOnly Cookie, React Router, Axios, Typescript và Taiwind.

Hi anh em, như trong các bài viết trước về JWT Authentication:

  1. Có phải bạn thường lưu JWT Token sau khi login vào LocalStorage ? (sharetolearn.vercel.app)
  2. Cách triển khai JWT Authentication với httpOnly cookie trong Nodejs Express (sharetolearn.vercel.app)


Tiếp theo hôm nay mình sẽ hướng dẫn anh em thực hiện setup react app làm authentication kết nối với các API của bài trước chúng ta đã làm xong để giúp anh em có thể hiểu được toàn bộ 1 luồng làm việc từ Backend đến Frontend nhé.


Bước 1: create react app

Trước đây mình thường dùng create-react-app tool để tạo nhanh một react app thay vì create project với webpack, nhưng mấy năm gần đây Vite đã trở nên rất phổ biến vì build rất nhanh, hỗ trợ đa nên tảng không chỉ mỗi với React mà còn có: Vue, Preact, Vanilla, svelte,... Chính vì thế mình chọn Vite làm công cụ để setup một react project mới.

Anh em muốn biết thêm thông tin có thể đọc document ở trang chủ của Vite: Getting Started | Vite (vitejs.dev)


Mình sẽ dùng React với typescript nên command như sau:

yarn create vite jwt-authentication-http-only-cookie --template react-ts


Rất nhanh chỉ vài giây chúng ta đã có ngay source code rồi, nếu dùng create-react-app chắc phải tính bằng phút =))


Để chạy app trên local anhh em gõ: yarn để install dependencies và yarn dev để start app trên local

yarn
yarn dev


Bước 2: Setup project

Cài tailwind css

Để dễ dàng cho việc styles thì mình sẽ dùng tailwind cho nó nhanh, cứ theo trang chủ nó hướng dẫn cách cài đặt mà làm nhé ae (Install Tailwind CSS with Vite - Tailwind CSS)

yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p


Sau khi run xong 2 commands trên thì anh em sửa file tailwind.config.js như hướng dẫn

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}


Xóa file app.tsx và app.css đi và xóa hết styles mặc định trong file src/index.css và thay thành:

@tailwind base;
@tailwind components;
@tailwind utilities;

Vậy là setup xong tailwind rồi.


Setup router

Cài đặt thư viện react-router-dom:

yarn add react-router-dom localforage match-sorter sort-by

Tạo PrivateRoute component:

Mình sẽ tạo auth context và useAuth hook để tiện cho việc check authetication trong PrivateRoute.


Trước hết cần tạo AuthContext và AuthProvider cho app trong folder src/context/authContext/AuthProvider.tsx:

//src/context/authContext/AuthProvider.tsx
import { ReactNode, createContext, useState } from "react";
import { User } from "../hooks/useUser";


interface AuthContext {
  user: User | null;
  setUser: (user: User | null) => void;
}


export const AuthContext = createContext<AuthContext>({
  user: null,
  setUser: () => {
    // do something.
  },
});


export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<User | null>(null);


  const value = {
    user,
    setUser,
  };


  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};


export default AuthContext;


Tạo custom hook useAuth để lấy thông tin user và các method login và logout:

//src/hooks/useAuth.ts
import { useContext } from "react";
import { AuthContext } from "../context/AuthProvider";


export interface User {
  id: string;
  name: string;
  email: string;
  roles: string[];
  authToken?: string;
}


const useAuth = () => {
  const { user, setUser } = useContext(AuthContext);


  const login = (user: User) => {
    setUser(user);
  };


  const logout = () => {
    setUser(null);
  };


  return { user, login, logout };
};


export default useAuth;


Tạo PrivateRoute component: ở đây mình chỉ check nếu user đã login thì coi như là xong rồi còn trường hợp anh em muốn check thêm cả roles để phân quyền cho từng page thì có thể update logic ở file này luôn.

//src/components/PrivateRoute.tsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
import useAuth from "../hooks/useAuh";


const PrivateRoute = () => {
  const { user } = useAuth();
  const location = useLocation();


  return user ? (
    <Outlet />
  ) : (
    <Navigate to="/login" state={{ from: location }} replace />
  );
};


export default PrivateRoute;


Tạo file root cho router ở thư mục src/routes/root.tsx

//src/routes/root.tsx
import {
  Route,
  createBrowserRouter,
  createRoutesFromElements,
} from "react-router-dom";
import Home from "../components/Home";
import Login from "../components/Login";
import PrivateRoute from "../components/PrivateRoute";


const router = createBrowserRouter(
  createRoutesFromElements(
    <Route>
      {/* public routes */}
      <Route path="/login" element={<Login />} />
      {/* protect routes */}
      <Route element={<PrivateRoute />}>
        <Route path="/" element={<Home />} />
      </Route>
      {/* catch all */}
      <Route path="*" element={<h1>Page not found</h1>} />
    </Route>,
  ),
);


export default router;

Các components Login, Home chút nữa mình sẽ tạo ở folder src/components sau.


Config router trong file main.tsx

// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./context/AuthProvider.tsx";
import "./index.css";
import router from "./routes/root.tsx";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  </React.StrictMode>,
);


Bước 3: Tạo login và home page UI

Phần này thì ae vẫn làm như bình thường thôi, chỉ lưu ý khi làm việc với các API login, logout thì cần set thêm withCredentials: true là được. Mình sẽ dùng axios để call API nên anh em cài thêm axios nhé:

yarn add axios


Mình sẽ tạo file config axios để tiện sử dụng nhiều chỗ luôn.

// src/config/axios.ts
import axios from "axios";

export default axios.create({
  baseURL: import.meta.env.VITE_BASE_URL,
  withCredentials: true,
  headers: {
    "Content-Type": "application/json",
  },
});


Implement Login component:

//src/components/Login.tsx
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useAuth from "../hooks/useAuh";
import axios from "../config/axios";


function Login() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from?.pathname || "/";
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");


  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    try {
      if (email && password) {
        const response = await axios.post(
          "/auth",
          JSON.stringify({ username: email, password })
        );
        
        login(response?.data?.user);
        setEmail("");
        setPassword("");
        navigate(from, { replace: true });
      }
    } catch (err) {
      console.log(err);
    }
  };


  return (
    <section className="bg-gray-50 ">
      <div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
        <div className="w-full bg-white rounded-lg shadow  md:mt-0 sm:max-w-md xl:p-0">
          <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
            <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl ">
              Sign in to your account
            </h1>
            <form className="space-y-4 md:space-y-6" onSubmit={handleSubmit}>
              <div>
                <label
                  htmlFor="username"
                  className="block mb-2 text-sm font-medium text-gray-900 ">
                  Your username
                </label>
                <input
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  type="text"
                  name="username"
                  id="username"
                  className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600    focus:border-primary-600 block w-full p-2.5"
                  placeholder="name"
                />
              </div>
              <div>
                <label
                  htmlFor="password"
                  className="block mb-2 text-sm font-medium text-gray-900">
                  Password
                </label>
                <input
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                  type="password"
                  name="password"
                  id="password"
                  placeholder="••••••••"
                  className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5"
                />
              </div>
              <button
                type="submit"
                className="w-full text-white bg-blue-700 hover:bg-blue-800 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center">
                Sign in
              </button>
            </form>
          </div>
        </div>
      </div>
    </section>
  );
}

export default Login;


Implement Home component:

//src/components/Home.tsx
import axios from "../config/axios";
import useAuth from "../hooks/useAuh";
import { useNavigate } from "react-router-dom";


function Home() {
  const { logout } = useAuth();
  const navigate = useNavigate();


  const handleLogout = async () => {
    try {
      await axios.post("/auth/logout");
      logout();
      navigate("/login");
    } catch (error) {
      console.log(error);
    }
    logout();
  };


  return (
    <>
      <div
        id="alert-additional-content-3"
        className="p-4 mb-4 text-green-800 border border-green-300 rounded-lg bg-green-50"
        role="alert">
        <div className="flex items-center">
          <svg
            aria-hidden="true"
            className="w-5 h-5 mr-2"
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg">
            <path
              fillRule="evenodd"
              d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
              clipRule="evenodd"></path>
          </svg>
          <span className="sr-only">Info</span>
          <h3 className="text-lg font-medium">Login success</h3>
        </div>
        <div className="mt-2 mb-4 text-sm">
          Welcome to share to learn!
          <div>
            Let talk about JWT authentication with httpOnly cookie in React app.
          </div>
        </div>
        <div className="flex">
          <button
            type="button"
            className="text-green-800 bg-transparent border border-green-800 hover:bg-green-900 hover:text-white focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center"
            onClick={handleLogout}
            aria-label="logout">
            Logout
          </button>
        </div>
      </div>
    </>
  );
}

export default Home;


Bước 4: Refresh token

Đến đây thì mình còn thiếu một phần rất quan trọng đó là làm sao để khi mình đã login rồi mà bấm F5 thì vẫn ở lại trang chứ không bị đẩy về trang login (Persist login)???

Bình thường nếu anh em mà lưu token vào Local Storage thì cái này đơn giản rồi chỉ cần check token có hay không thôi nhưng không sao mình sẽ dùng API refresh token mà ở phần làm BE chúng ta đã làm rồi để check xem user đã login hay chưa.


Ok vậy anh em cùng thực hiện bằng cách sửa lại component PrivateRoute ở folder src/components/PrivateRoute.tsx như sau:

import { useEffect, useState } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import axios from "../config/axios";
import useAuth from "../hooks/useAuh";


const PrivateRoute = () => {
  const { user, login } = useAuth();
  const location = useLocation();
  const [loading, setLoading] = useState<boolean>(true);


  useEffect(() => {
    let isMounted = true;

    const verifyRefreshToken = async () => {
      try {
        const refreshResponse = await axios.get("/auth/refresh");
        login(refreshResponse?.data?.user);
      } catch (error) {
        console.error(error);
      } finally {
        if (isMounted) setLoading(false);
      }
    };


    if (!user) {
      verifyRefreshToken();
    } else {
      setLoading(false);
    }


    return () => {
      isMounted = false;
    };
  }, []);


  if (loading) return <h1>Loading...</h1>;


  return user ? (
    <Outlet />
  ) : (
    <Navigate to="/login" state={{ from: location }} replace />
  );
};


export default PrivateRoute;


Ở đây thì mình sẽ call api refresh token nếu như user là null (tức là chưa login) nếu thành công thì nó trả về thông tin user như khi đăng nhập vậy mình dùng hàm login từ hook useAuth để save user với thông tin này lại. Vậy nếu có thông tin user rồi thì return <Outlet /> còn không thì back về trang login.


Lưu ý: Cũng giống với API login hay logout thì bất cứ API nào mà cần sử dụng cookie thì đều phải set thêm withCredentials: true thì ở phía BE mới có thể lấy được thông tin từ cookie để check authentication nhé. Nhiều anh em hay quên cái này nên không hay gặp lỗi không set được cookie hoặc login set được rồi mà khi refresh lại không được :))


Tổng kết:

Như vậy mình đã hoàn thành xong jwt authentication trong react app với httpOnly cookie mà không cần phải lưu token vào Local Storage. Nhưng với trường hợp một số API cần phải set token vào header thì anh em nên lưu token bằng một biến hay state trong React nhé!


Link project gồm cả BE và FE anh em có thể tham khảo ở đây: Gnutyud/jwt-authentication-http-only-cookie (github.com)