Tìm kiếm trang web

Cách Thêm Xác Thực Đăng Nhập vào Ứng Dụng React


Tác giả đã chọn Creative Commons để nhận quyên góp như một phần của chương trình Viết để Quyên góp.

Giới thiệu

Xác thực trong các ứng dụng React quản lý quyền truy cập của người dùng vào các trang và tài nguyên được bảo vệ. Khi người dùng đăng nhập, ứng dụng của bạn lưu thông tin xác thực (thường là token) và kiểm tra chúng trước khi hiển thị các tuyến riêng tư. Trạng thái xác thực phải được duy trì khi làm mới trang, điều này yêu cầu lưu token trong bộ nhớ trình duyệt thay vì trong state của component.

Cách trạng thái xác thực được duy trì: Khi bạn lưu token trong localStorage hoặc sessionStorage, chúng sẽ tồn tại qua các lần làm mới trang và khởi động lại trình duyệt (đối với localStorage). Điều này có nghĩa là ứng dụng của bạn có thể kiểm tra token hiện có khi được khởi tạo và tự động xác thực người dùng mà không cần đăng nhập lại. Nếu không có bộ nhớ lưu trữ bền, người dùng sẽ phải đăng nhập mỗi lần làm mới trang hoặc mở tab mới.

Hướng dẫn này hướng dẫn cách xây dựng một ứng dụng React với xác thực dựa trên token sử dụng React Router v6 cho các tuyến đường bảo vệ. Bạn sẽ tạo một API giả lập trả về các token xác thực, triển khai một trang đăng nhập để lấy và lưu trữ token, đồng thời thiết lập các kiểm tra xác thực để bảo toàn tuyến đường mà người dùng dự định truy cập. Hướng dẫn cũng bao gồm các phương pháp lưu trữ token khác nhau (localStorage, sessionStorage) và các tác động bảo mật của chúng, triển khai chức năng đăng xuất và xử lý lỗi, đồng thời minh họa cách giải mã token JWT để kiểm tra thời hạn.

Tại sao logic xác thực không nên nằm trong từng thành phần riêng lẻ: Việc tập trung logic xác thực trong một hook tùy chỉnh hoặc React Context đảm bảo hành vi nhất quán trên toàn bộ ứng dụng, giúp dễ dàng cập nhật các luồng xác thực và ngăn chặn trùng lặp mã. Khi logic xác thực bị phân tán trong các thành phần, bạn có nguy cơ kiểm tra token không nhất quán, bỏ sót các trường hợp đặc biệt và gặp khó khăn trong việc bảo trì.

Sau khi hoàn thành hướng dẫn này, bạn sẽ hiểu cách triển khai xác thực an toàn trong các ứng dụng React, chọn chiến lược lưu trữ token phù hợp dựa trên yêu cầu về bảo mật và trải nghiệm người dùng, đồng thời xử lý các tình huống xác thực phổ biến bao gồm các tuyến được bảo vệ, hết hạn token và đăng xuất.

Cần triển khai một dự án React nhanh chóng? Hãy xem qua Nền tảng Ứng dụng Linux-Console.net và triển khai dự án React trực tiếp từ GitHub chỉ trong vài phút.

Điểm chính

  • Trạng thái xác thực được duy trì khi tải lại trang nếu được lưu trong localStorage hoặc sessionStorage. Các token lưu trong trạng thái của component (useState) sẽ bị mất khi tải lại, buộc người dùng phải đăng nhập lại. Các API lưu trữ của trình duyệt giữ token giữa các phiên, cho phép xác thực lại tự động.

  • React Router v6 sử dụng Routes thay vì Switch và yêu cầu thuộc tính element thay vì children. API mới mang tính khai báo hơn và an toàn với kiểu dữ liệu. Các route được bảo vệ được triển khai bằng cách sử dụng một component bao bọc, kiểm tra xác thực trước khi render.

  • Tập trung logic xác thực trong một hook tùy chỉnh hoặc React Context. Giữ logic xác thực trong từng thành phần riêng lẻ sẽ dẫn đến việc trùng lặp và hành vi không nhất quán. Một hook useToken tùy chỉnh hoặc AuthContext cung cấp một nguồn thông tin duy nhất cho trạng thái xác thực.

  • localStorage tồn tại qua các phiên trình duyệt; sessionStorage chỉ dành cho từng tab. Sử dụng localStorage cho chức năng “ghi nhớ tôi” và sessionStorage cho xác thực chỉ trong phiên và bảo mật hơn. Cả hai đều có nguy cơ bị tấn công XSS, vì vậy luôn luôn làm sạch dữ liệu đầu vào của người dùng.

  • Token JWT có thể được giải mã trên phía khách hàng để kiểm tra hạn sử dụng mà không cần truy vấn lại máy chủ. Payload chứa một trường exp (dấu thời gian Unix) mà bạn có thể so sánh với thời gian hiện tại. Tuy nhiên, luôn xác thực token trên máy chủ vì các kiểm tra phía khách hàng chỉ nhằm mục đích trải nghiệm người dùng.

  • Bảo vệ CSRF là rất quan trọng khi sử dụng xác thực dựa trên cookie. Cookie được gửi tự động kèm theo các yêu cầu, làm cho chúng dễ bị tấn công CSRF. Sử dụng token CSRF, thuộc tính cookie SameSite và xác minh header Origin trên server.

Điều kiện tiên quyết

  • Một môi trường phát triển chạy Node.js phiên bản 18.x và npm phiên bản 9.x. Để cài đặt Node.js trên macOS hoặc Ubuntu, hãy làm theo các bước trong Hướng dẫn Cài đặt Node.js và Tạo Môi trường Phát triển Cục bộ trên macOS hoặc Hướng dẫn Cài đặt Node.js trên Ubuntu.

  • Một môi trường phát triển React được thiết lập bằng Create React App, với các phần mẫu không cần thiết đã được loại bỏ. Để thiết lập điều này, hãy làm theo Bước 1 — Tạo một Dự án Trống trong hướng dẫn Cách Quản lý State trên Các Thành phần Lớp React. Hướng dẫn này sử dụng auth-tutorial làm tên dự án.

  • Quen thuộc với việc lấy dữ liệu từ API trong React. Để tìm hiểu thêm về làm việc với API, xem Cách Gọi Web API với Hook useEffect trong React.

  • Kiến thức cơ bản về JavaScript, HTML và CSS. Tìm hiểu thêm trong loạt bài Cách Xây Dựng Trang Web Với HTML, Cách Định Dạng HTML với CSS, và Cách Lập Trình với JavaScript của chúng tôi.

Bước 1 — Xây dựng trang đăng nhập

Các trang đăng nhập chặn người dùng chưa được xác thực trước khi họ đến các tuyến đường được bảo vệ. Thay vì chuyển hướng người dùng đến một tuyến đăng nhập chuyên dụng (mất điểm đến dự định của họ), hãy hiển thị biểu mẫu đăng nhập trên bất kỳ tuyến nào khi không có mã xác thực. Điều này giữ nguyên ý định điều hướng của người dùng khi truy cập /dashboard trong khi đăng xuất hiển thị biểu mẫu đăng nhập và sau khi xác thực, họ sẽ truy cập /dashboard thay vì được chuyển hướng đến nơi khác.

Tại sao nên hiển thị đăng nhập trên bất kỳ đường dẫn nào: Khi người dùng truy cập một đường dẫn bảo vệ như /dashboard mà chưa đăng nhập, việc hiển thị biểu mẫu đăng nhập ngay trên cùng đường dẫn đó giúp giữ nguyên bối cảnh điều hướng của họ. Sau khi đăng nhập, họ đã ở đúng đích đến mà không cần chuyển hướng thêm. Điều này tạo ra trải nghiệm người dùng mượt mà hơn so với việc chuyển hướng đến /login rồi lại yêu cầu một bước điều hướng khác.

Cài đặt React Router bằng npm. Có hai phiên bản: phiên bản web cho các ứng dụng React và phiên bản native cho React Native. Cài đặt phiên bản web:

npm install react-router-dom

Việc cài đặt hoàn tất với đầu ra tương tự như sau:

...
+ react-router-dom@6.28.0
added 11 packages from 6 contributors, removed 10 packages and audited 1945 packages in 12.794s
...

Tiếp theo, tạo hai thành phần có tên Dashboard và Preferences để đóng vai trò như các trang riêng tư. Chúng sẽ đại diện cho các thành phần mà người dùng không nên nhìn thấy cho đến khi họ đã đăng nhập thành công vào ứng dụng.

Đầu tiên, tạo các thư mục:

mkdir src/components/Dashboard

mkdir src/components/Preferences

Mở Dashboard.js trong một trình soạn thảo văn bản:

nano src/components/Dashboard/Dashboard.js

Bên trong Dashboard.js, thêm một thẻ <h2> với nội dung là Dashboard:

import React from 'react';

export default function Dashboard() {
  return(
    <h2>Dashboard</h2>
  );
}

Lưu và đóng tệp.

Lặp lại các bước tương tự cho Preferences. Mở thành phần:

nano src/components/Preferences/Preferences.js

Thêm nội dung:

import React from 'react';

export default function Preferences() {
  return(
    <h2>Preferences</h2>
  );
}

Lưu và đóng tệp.

Bây giờ bạn đã có một số thành phần, bạn cần nhập các thành phần này và tạo các tuyến đường bên trong App.js. Hãy xem hướng dẫn Cách Quản Lý Routing trong Ứng Dụng React với React Router để có một giới thiệu đầy đủ về routing trong các ứng dụng React. Để triển khai ứng dụng React của bạn, xem Cách Triển Khai Ứng Dụng React với Nginx trên Ubuntu.

Để bắt đầu, mở App.js:

nano src/components/App/App.js

Sau đó, nhập Dashboard và Preferences bằng cách thêm đoạn mã được tô sáng sau:

import React from 'react';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';

function App() {
  return (
    <></>
  );
}

export default App;

Tiếp theo, nhập BrowserRouter, Routes và Route từ react-router-dom. React Router v6 sử dụng Routes thay vì Switch, và các route sử dụng thuộc tính element thay vì children:

import React from 'react';
import './App.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';

function App() {
  return (
    <></>
  );
}

export default App;

Thêm một <div> bao quanh với className là wrapper và một thẻ <h1> để làm mẫu cho ứng dụng. Hãy chắc chắn rằng bạn đang import App.css để có thể áp dụng các kiểu dáng.

Tiếp theo, tạo các tuyến đường cho các thành phần Dashboard và Preferences. Thêm BrowserRouter, sau đó thêm một thành phần Routes làm con. Bên trong Routes, thêm một Route với thuộc tính path và element cho từng thành phần:

import React from 'react';
import './App.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';

function App() {
  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Lưu và đóng tệp.

Bước cuối cùng là thêm một số khoảng đệm vào <div> chính để thành phần của bạn không nằm trực tiếp ở mép của trình duyệt. Để làm điều này, bạn sẽ thay đổi CSS.

Mở App.css:

nano src/components/App/App.css

Thay thế nội dung bằng một lớp .wrapper với padding là 20px:

.wrapper {
    padding: 20px;
}

Lưu và đóng tệp. Trình duyệt sẽ tự động tải lại, hiển thị các thành phần cơ bản:

Truy cập http://localhost:3000/dashboard để xem trang bảng điều khiển:

Các tuyến đường hoạt động, nhưng /dashboard và /preferences hiện có thể truy cập bởi bất kỳ ai, bao gồm cả người dùng chưa đăng nhập. Đây nên là các tuyến đường được bảo vệ.

Hai cách để bảo vệ các tuyến đường:

  1. Chuyển hướng đến đường dẫn đăng nhập riêng: Tạo một đường dẫn /login và chuyển hướng người dùng chưa xác thực đến đó. Cách này vẫn hoạt động, nhưng người dùng sẽ mất địa chỉ đích ban đầu và phải tự quay lại sau khi đăng nhập.

  2. Hiển thị biểu mẫu đăng nhập trên bất kỳ tuyến đường nào (được sử dụng trong hướng dẫn này): Hiển thị biểu mẫu đăng nhập trên tuyến đường hiện tại khi chưa có token. Sau khi xác thực, người dùng vẫn ở tại điểm đến mà họ dự định. Ví dụ, khi truy cập /dashboard trong lúc chưa đăng nhập sẽ hiển thị biểu mẫu đăng nhập, và sau khi đăng nhập, người dùng sẽ được đưa trực tiếp đến /dashboard.

Hướng dẫn này sử dụng phương pháp thứ hai để cải thiện trải nghiệm người dùng.

Để bắt đầu, tạo một thư mục mới cho thành phần Login:

mkdir src/components/Login

Tiếp theo, mở Login.js trong một trình soạn thảo văn bản:

nano src/components/Login/Login.js

Tạo một biểu mẫu cơ bản với một <button> gửi và một <input> cho tên người dùng và mật khẩu. Hãy chắc chắn đặt loại input cho mật khẩu là password:

import React from 'react';

export default function Login() {
  return(
    <form>
      <label>
        <p>Username</p>
        <input type="text" />
      </label>
      <label>
        <p>Password</p>
        <input type="password" />
      </label>
      <div>
        <button type="submit">Submit</button>
      </div>
    </form>
  )
}

Để tìm hiểu thêm về các biểu mẫu trong React, hãy xem hướng dẫn Cách Xây Dựng Biểu Mẫu trong React. Để tìm hiểu về quản lý trạng thái trong các thành phần React, hãy xem Cách Quản Lý Trạng Thái với Hooks trên Các Thành Phần React.

Tiếp theo, thêm một thẻ <h1> yêu cầu người dùng đăng nhập. Bao quanh thẻ <form> và <h1> trong một thẻ <div> với className là login-wrapper. Cuối cùng, nhập Login.css:

import React from 'react';
import './Login.css';

export default function Login() {
  return(
    <div className="login-wrapper">
      <h1>Please Log In</h1>
      <form>
        <label>
          <p>Username</p>
          <input type="text" />
        </label>
        <label>
          <p>Password</p>
          <input type="password" />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  )
}

Lưu và đóng tệp.

Thêm kiểu để căn giữa biểu mẫu đăng nhập. Mở Login.css:

nano src/components/Login/Login.css

Căn giữa thành phần trên trang bằng cách thêm thuộc tính display với giá trị flex, sau đó đặt flex-direction là column để căn các phần tử theo chiều dọc và thêm align-items với giá trị center để thành phần được căn giữa trong trình duyệt:

.login-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
}

Để biết thêm thông tin về cách sử dụng Flexbox, hãy xem Bảng tóm tắt CSS Flexbox của chúng tôi

Lưu và đóng tệp.

Hiển thị thành phần đăng nhập trong App.js khi không có token xác thực. Mở App.js:

nano src/components/App/App.js

Trong Bước 3, bạn sẽ triển khai lưu trữ token lâu dài. Hiện tại, hãy lưu token trong state của component bằng cách sử dụng Hook useState để thử nghiệm luồng đăng nhập. Lưu ý rằng các token được lưu trong state của component sẽ bị mất khi trang được làm mới.

Nhập useState từ react, sau đó gọi useState và gán các giá trị trả về cho token và setToken:

import React, { useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Preferences from '../Preferences/Preferences';

function App() {
  const [token, setToken] = useState();
  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Nhập component Login. Thêm một câu lệnh điều kiện để hiển thị Login nếu token là giá trị falsy.

Truyền hàm setToken vào thành phần Login:

import React, { useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';

function App() {
  const [token, setToken] = useState();

  if(!token) {
    return <Login setToken={setToken} />
  }

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Vì chưa có token nào tồn tại, biểu mẫu đăng nhập sẽ hiển thị trên tất cả các tuyến đường. Trong bước tiếp theo, bạn sẽ tạo một điểm cuối API trả về token sau khi xác thực thành công.

Lưu và đóng tệp. Trình duyệt sẽ tự động tải lại, hiển thị trang đăng nhập trên tất cả các tuyến. Truy cập trang bảng điều khiển để xác nhận biểu mẫu đăng nhập xuất hiện:

Bạn đã tạo một biểu mẫu đăng nhập hiển thị trên tất cả các tuyến đường khi không có mã xác thực. Biểu mẫu này ngăn truy cập vào các tuyến đường được bảo vệ như /dashboard và /preferences cho đến khi việc xác thực thành công. Trong bước tiếp theo, bạn sẽ tạo một máy chủ API giả lập trả về các mã xác thực.

Bước 2 — Tạo API Token

API giả lập mô phỏng xác thực backend trong quá trình phát triển mà không cần máy chủ sản xuất. Express cung cấp một cách đơn giản để tạo máy chủ phát triển trả về các token theo cùng định dạng mà API sản xuất của bạn sẽ sử dụng. Điều này cho phép bạn kiểm tra các luồng xác thực, xử lý phản hồi API và phát triển frontend một cách độc lập.

Tại sao nên sử dụng API giả: Trong quá trình phát triển, bạn có thể chưa có backend sẵn sàng, hoặc bạn muốn thử nghiệm các luồng xác thực mà không cần kết nối với máy chủ sản xuất. Một API giả sẽ trả về các phản hồi có thể dự đoán được, giúp dễ dàng kiểm tra xử lý lỗi, trạng thái tải và quản lý token. Đối với ứng dụng sản xuất, hãy thay thế API giả bằng điểm cuối xác thực thực tế của bạn.

Tạo một máy chủ Express đơn giản trả về một token. Để tìm hiểu chi tiết về Express, xem Basic Express Server in Node.js. Nếu bạn đang xây dựng một ứng dụng full-stack, hãy tham khảo How To Build a To-Do Application Using Django and React để có một ví dụ đầy đủ.

Để bắt đầu, cài đặt express. Vì server không phải là yêu cầu trong bản build cuối cùng, hãy chắc chắn cài đặt nó như một devDependency.

Bạn cũng cần cài đặt cors. Thư viện này sẽ cho phép chia sẻ tài nguyên giữa các nguồn cho tất cả các tuyến.

Cảnh báo: Không kích hoạt CORS cho tất cả các tuyến trong ứng dụng sản xuất. Điều này có thể dẫn đến lỗ hổng bảo mật.

npm install --save-dev express cors

Việc cài đặt hoàn tất với đầu ra tương tự như sau:

...
+ cors@2.8.5
+ express@4.17.1
removed 10 packages, updated 2 packages and audited 2059 packages in 12.597s
...

Tiếp theo, mở một tệp mới có tên server.js ở thư mục gốc của ứng dụng của bạn. Không thêm tệp này vào thư mục /src vì bạn không muốn nó là một phần của bản build cuối cùng.

nano server.js

Nhập express, sau đó khởi tạo một ứng dụng mới bằng cách gọi express() và lưu kết quả vào một biến có tên là app:

const express = require('express');
const app = express();

Sau khi tạo app, thêm cors như một middleware. Đầu tiên, import cors, sau đó thêm nó vào ứng dụng bằng cách gọi phương thức use trên app:

const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors());

Thêm một trình xử lý tuyến đường cho /login để trả về một token. Trình xử lý nhận req (dữ liệu yêu cầu) và res (đối tượng phản hồi). Sử dụng res.send để trả về một đối tượng JavaScript chứa token:

const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors());

app.use('/login', (req, res) => {
  res.send({
    token: 'test123'
  });
});

Cuối cùng, chạy máy chủ trên cổng 8080 bằng cách sử dụng app.listen:

const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors());

app.use('/login', (req, res) => {
  res.send({
    token: 'test123'
  });
});

app.listen(8080, () => console.log('API is running on http://localhost:8080'));

Lưu và đóng tệp. Trong một cửa sổ hoặc tab terminal mới, khởi động máy chủ:

node server.js

Máy chủ khởi động và hiển thị:

API is running on http://localhost:8080/login

Truy cập trang đăng nhập trên trình duyệt của bạn để xem phản hồi đối tượng JSON:

Lưu ý: Truy cập URL trong trình duyệt sẽ gửi một yêu cầu GET, trong khi biểu mẫu đăng nhập sẽ gửi một yêu cầu POST. Bộ xử lý tuyến hiện tại chấp nhận cả hai phương thức. Trong môi trường sản xuất, hãy giới hạn các tuyến cho các phương thức HTTP cụ thể bằng cách sử dụng app.post() cho các yêu cầu POST và app.get() cho các yêu cầu GET. Xem tài liệu định tuyến của Express để biết chi tiết.

Bây giờ bạn đã có một máy chủ API đang chạy, bạn cần thực hiện một yêu cầu từ trang đăng nhập của mình. Mở Login.js:

nano src/components/Login/Login.js

Trong bước trước, bạn đã truyền một prop mới có tên setToken vào component Login. Thêm PropType từ prop mới và giải cấu trúc đối tượng props để lấy ra prop setToken.

import React from 'react';
import PropTypes from 'prop-types';

import './Login.css';

export default function Login({ setToken }) {
  return(
    <div className="login-wrapper">
      <h1>Please Log In</h1>
      <form>
        <label>
          <p>Username</p>
          <input type="text" />
        </label>
        <label>
          <p>Password</p>
          <input type="password" />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  )
}

Login.propTypes = {
  setToken: PropTypes.func.isRequired
}

Tiếp theo, tạo một state cục bộ để lưu trữ Username và Password. Vì bạn không cần phải thiết lập dữ liệu thủ công, hãy làm cho các <inputs> trở thành các thành phần không điều khiển. Bạn có thể tìm thông tin chi tiết về các thành phần không điều khiển trong Hướng Dẫn Xây Dựng Biểu Mẫu Trong React.

import React, { useState } from 'react';
import PropTypes from 'prop-types';

import './Login.css';

export default function Login({ setToken }) {
  const [username, setUserName] = useState();
  const [password, setPassword] = useState();
  return(
    <div className="login-wrapper">
      <h1>Please Log In</h1>
      <form>
        <label>
          <p>Username</p>
          <input type="text" onChange={e => setUserName(e.target.value)}/>
        </label>
        <label>
          <p>Password</p>
          <input type="password" onChange={e => setPassword(e.target.value)}/>
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  )
}

Login.propTypes = {
  setToken: PropTypes.func.isRequired
};

Tạo một hàm để thực hiện yêu cầu POST tới máy chủ. Trong các ứng dụng lớn hơn, đặt các cuộc gọi API vào một thư mục dịch vụ riêng. Trong hướng dẫn này, hàm được định nghĩa trong tệp component. Để tìm hiểu thêm về các cuộc gọi API trong React, xem Cách Gọi Web API với Hook useEffect trong React.

Tạo một hàm async có tên là loginUser. Hàm này sẽ nhận credentials làm đối số, sau đó gọi phương thức fetch sử dụng tùy chọn POST:

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';

async function loginUser(credentials) {
 return fetch('http://localhost:8080/login', {
   method: 'POST',
   headers: {
     'Content-Type': 'application/json'
   },
   body: JSON.stringify(credentials)
 })
   .then(data => data.json())
}

export default function Login({ setToken }) {
...

Cuối cùng, tạo một hàm xử lý gửi biểu mẫu gọi là handleSubmit sẽ gọi loginUser với username và password. Gọi setToken với kết quả thành công. Gọi handleSubmit bằng cách sử dụng trình xử lý sự kiện onSubmit trên <form>:

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';

async function loginUser(credentials) {
 return fetch('http://localhost:8080/login', {
   method: 'POST',
   headers: {
     'Content-Type': 'application/json'
   },
   body: JSON.stringify(credentials)
 })
   .then(data => data.json())
}

export default function Login({ setToken }) {
  const [username, setUserName] = useState();
  const [password, setPassword] = useState();

  const handleSubmit = async e => {
    e.preventDefault();
    const token = await loginUser({
      username,
      password
    });
    setToken(token);
  }

  return(
    <div className="login-wrapper">
      <h1>Please Log In</h1>
      <form onSubmit={handleSubmit}>
        <label>
          <p>Username</p>
          <input type="text" onChange={e => setUserName(e.target.value)} />
        </label>
        <label>
          <p>Password</p>
          <input type="password" onChange={e => setPassword(e.target.value)} />
        </label>
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
    </div>
  )
}

Login.propTypes = {
  setToken: PropTypes.func.isRequired
};

Lưu ý: Trong các ứng dụng thực tế, hãy xử lý việc gỡ bỏ component trong các hoạt động bất đồng bộ. Nếu người dùng chuyển sang trang khác trước khi một Promise được giải quyết, bạn có thể cần hủy yêu cầu hoặc bỏ qua phản hồi. Xem Hướng dẫn Gọi Web API với Hook useEffect trong React để biết các mẫu dọn dẹp.

Lưu và đóng tệp. Đảm bảo máy chủ API vẫn đang chạy, sau đó truy cập http://localhost:3000/dashboard trên trình duyệt của bạn.

Trang đăng nhập xuất hiện. Nhập bất kỳ tên người dùng và mật khẩu nào, sau đó gửi biểu mẫu. API trả về một token, và ứng dụng chuyển hướng đến bảng điều khiển.

Quy trình đăng nhập hoạt động: khi gửi biểu mẫu sẽ gọi API và lưu trữ token trả về. Tuy nhiên, token được lưu trong trạng thái của component (bộ nhớ JavaScript), điều này có nghĩa là nó sẽ bị mất khi làm mới trang hoặc khi mở tab mới. Người dùng sẽ cần đăng nhập lại sau mỗi lần làm mới.

Vấn đề: Trạng thái của component (useState) không được giữ nguyên khi tải lại trang. Khi trình duyệt làm mới, React sẽ khởi tạo lại các component và mọi trạng thái lưu trong bộ nhớ sẽ bị mất.

Trong bước tiếp theo, bạn sẽ triển khai lưu trữ token bền vững bằng cách sử dụng các API lưu trữ của trình duyệt (localStorage hoặc sessionStorage) để xác thực được giữ nguyên qua các lần làm mới trang và các phiên trình duyệt.

Bước 3 — Lưu trữ Token Người dùng với sessionStorage và localStorage

Vị trí lưu trữ token quyết định khả năng duy trì phiên và sự đánh đổi về bảo mật. localStorage giữ người dùng đăng nhập qua các phiên trình duyệt và tab, trong khi sessionStorage sẽ bị xóa khi tab đóng. Cả hai đều có thể truy cập được bằng JavaScript, khiến chúng dễ bị tấn công XSS, nhưng chúng mang lại trải nghiệm người dùng tốt hơn so với việc chỉ lưu token trong trạng thái của component.

So sánh các tùy chọn lưu trữ:

  • Trạng thái thành phần (useState): Mất khi làm mới trang. Không có sự lưu trữ.
  • sessionStorage: Lưu trữ trong phiên duyệt của tab. Bị xóa khi tab đóng. Không được chia sẻ giữa các tab.
  • localStorage: Lưu giữ dữ liệu qua các phiên và tab trình duyệt. Dữ liệu tồn tại ngay cả khi khởi động lại trình duyệt. Chia sẻ giữa tất cả các tab cùng nguồn gốc.
  • Cookies: Tự động gửi kèm với các yêu cầu. Yêu cầu bảo vệ CSRF. Có thể là httpOnly (không thể truy cập bằng JavaScript, an toàn hơn).

Cân nhắc về bảo mật: Bất kỳ dữ liệu nào được lưu trữ bên ngoài bộ nhớ của thành phần (bao gồm localStorage, sessionStorage và cookie có thể truy cập bằng JavaScript) đều dễ bị tấn công Cross-Site Scripting (XSS). Nếu mã độc chạy trên miền của bạn, nó có thể truy cập các cơ chế lưu trữ này. Luôn làm sạch dữ liệu đầu vào từ người dùng và sử dụng tiêu đề Chính sách Bảo mật Nội dung (Content Security Policy - CSP) để giảm thiểu rủi ro XSS.

Hướng dẫn này đề cập đến sessionStorage và localStorage như những lựa chọn hiện đại thay thế cho cookie trong việc lưu trữ token. Cookie được đề cập trong Bước 8 khi thảo luận về bảo vệ CSRF.

Bộ nhớ phiên

Để kiểm tra lợi ích của việc lưu trữ ngoài bộ nhớ, hãy chuyển lưu trữ trong bộ nhớ sang sessionStorage. Mở App.js:

nano src/components/App/App.js

Xóa cuộc gọi đến useState và tạo hai hàm mới gọi là setToken và getToken. Sau đó gọi getToken và gán kết quả cho một biến gọi là token:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';

function setToken(userToken) {
}

function getToken() {
}

function App() {
  const token = getToken();

  if(!token) {
    return <Login setToken={setToken} />
  }

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Sử dụng cùng tên hàm (setToken, getToken) và tên biến (token) có nghĩa là thành phần Login và phần còn lại của App.js không cần thay đổi. Giao diện vẫn giữ nguyên, nhưng phần triển khai bây giờ sử dụng lưu trữ lâu dài.

Triển khai setToken để lưu token vào sessionStorage. Phương thức setItem yêu cầu một khóa (chuỗi) và một giá trị (chuỗi), vì vậy hãy chuyển đối tượng userToken thành chuỗi JSON sử dụng JSON.stringify:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';

function setToken(userToken) {
  sessionStorage.setItem('token', JSON.stringify(userToken));
}

function getToken() {
}

function App() {
  const token = getToken();

  if(!token) {
    return <Login setToken={setToken} />
  }

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Lưu tập tin. Trình duyệt sẽ tự động tải lại. Sau khi gửi biểu mẫu đăng nhập, token sẽ được lưu vào sessionStorage, nhưng trang đăng nhập vẫn hiển thị vì React chưa kịp render lại. Mở công cụ phát triển của trình duyệt (F12) và kiểm tra tab Storage để xác nhận token đã được lưu. Hình ảnh dưới đây hiển thị công cụ phát triển của Firefox; Chrome và các trình duyệt hiện đại khác cũng có công cụ kiểm tra lưu trữ tương tự.

Triển khai getToken để lấy token từ sessionStorage. Phương thức getItem trả về chuỗi đã lưu (hoặc null nếu không có giá trị). Phân tích chuỗi JSON trở lại thành đối tượng, sau đó trả về thuộc tính token:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';

function setToken(userToken) {
  sessionStorage.setItem('token', JSON.stringify(userToken));
}

function getToken() {
  const tokenString = sessionStorage.getItem('token');
  if (!tokenString) {
    return null;
  }
  const userToken = JSON.parse(tokenString);
  return userToken?.token;
}

function App() {
  const token = getToken();

  if(!token) {
    return <Login setToken={setToken} />
  }

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Tại sao nên sử dụng optional chaining (?.): Lần đầu truy cập, sessionStorage.getItem('token') trả về null, và JSON.parse(null) trả về null. Nếu không dùng optional chaining, việc truy cập userToken.token khi userToken là null sẽ gây ra TypeError. Toán tử ?. sẽ trả về undefined an toàn thay vì gây lỗi.

Lưu và đóng tệp. Nếu có một token trong sessionStorage, trình duyệt sẽ tự động điều hướng đến các trang được bảo vệ:

Để kiểm tra lại quy trình đăng nhập, hãy xóa token đã lưu. Trong công cụ dành cho nhà phát triển của trình duyệt, bạn có thể xóa token trong tab Storage hoặc chạy sessionStorage.clear() trong console.

Vấn đề: Sau khi đăng nhập, token được lưu vào sessionStorage, nhưng trang đăng nhập vẫn hiển thị. Điều này xảy ra vì React không biết rằng token đã được lưu, và component cần được render lại khi token thay đổi.

Tại sao React không tái hiển thị: Lưu dữ liệu trong sessionStorage không kích hoạt React tái hiển thị. React chỉ tái hiển thị khi state thay đổi. Để khắc phục, hãy sử dụng state của React để theo dõi token, và cập nhật state đó khi lưu hoặc lấy token. Điều này sẽ kích hoạt tái hiển thị và cập nhật giao diện người dùng.

Giải pháp: Tạo một Hook tùy chỉnh kết hợp các thao tác lưu trữ với trạng thái React. Điều này giúp tập trung logic quản lý token và làm cho nó có thể tái sử dụng trong các component.

Tạo một Hook Token Tùy chỉnh

Hook tùy chỉnh là gì: Hook tùy chỉnh là một hàm JavaScript sử dụng các React Hook (như useState, useEffect) và đóng gói logic có thể tái sử dụng. Theo quy ước, các Hook tùy chỉnh bắt đầu với use* (ví dụ: useToken, useAuth). Hook tùy chỉnh cho phép bạn tách logic của component thành các hàm có thể tái sử dụng, giúp code dễ bảo trì và kiểm thử hơn.

Mở một tệp mới trong thư mục App có tên là useToken.js:

nano src/components/App/useToken.js

Tổ chức tệp: Hook này có thể được định nghĩa trực tiếp trong App.js, nhưng tách nó ra một tệp riêng cho thấy cách các Hook tùy chỉnh hoạt động độc lập với các component. Khi ứng dụng của bạn phát triển và bạn sử dụng lại Hook này trong nhiều component, việc giữ nó trong một tệp riêng sẽ dễ dàng hơn cho việc bảo trì và kiểm thử.

Trong useToken.js, hãy import useState từ react. Vì tệp này không chứa JSX, bạn không cần import React. Tạo và xuất một hàm có tên useToken sử dụng useState để quản lý trạng thái token:

import { useState } from 'react';

export default function useToken() {
  const [token, setToken] = useState();

}

Thêm hàm getToken dưới dạng hàm mũi tên bên trong useToken. Đặt nó trước khai báo state, sau đó khởi tạo useState với getToken() để tải bất kỳ token nào có sẵn khi component được mount:

import { useState } from 'react';

export default function useToken() {
  const getToken = () => {
    const tokenString = sessionStorage.getItem('token');
    if (!tokenString) {
      return null;
    }
    const userToken = JSON.parse(tokenString);
    return userToken?.token;
  };
  const [token, setToken] = useState(getToken());

}

Thêm một hàm saveToken để lưu token cả trong sessionStorage và state của React. Lưu vào state sẽ kích hoạt việc render lại, cập nhật giao diện người dùng:

import { useState } from 'react';

export default function useToken() {
  const getToken = () => {
    const tokenString = sessionStorage.getItem('token');
    if (!tokenString) {
      return null;
    }
    const userToken = JSON.parse(tokenString);
    return userToken?.token;
  };

  const [token, setToken] = useState(getToken());

  const saveToken = userToken => {
    sessionStorage.setItem('token', JSON.stringify(userToken));
    setToken(userToken.token);
  };
}

Trả về một đối tượng với token và setToken (được ánh xạ tới saveToken). Việc sử dụng một đối tượng thay vì mảng cho phép các component chỉ lấy ra các giá trị họ cần. Điều này vẫn giữ nguyên giao diện như trước, nên App.js không cần thay đổi:

import { useState } from 'react';

export default function useToken() {
  const getToken = () => {
    const tokenString = sessionStorage.getItem('token');
    if (!tokenString) {
      return null;
    }
    const userToken = JSON.parse(tokenString);
    return userToken?.token;
  };

  const [token, setToken] = useState(getToken());

  const saveToken = userToken => {
    sessionStorage.setItem('token', JSON.stringify(userToken));
    setToken(userToken.token);
  };

  return {
    setToken: saveToken,
    token
  }
}

Lưu và đóng tệp.

Tiếp theo, mở App.js:

nano src/components/App/App.js

Xóa các hàm getToken và setToken khỏi App.js, và xóa import useState. Thay vào đó, import và sử dụng Hook useToken:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import useToken from './useToken';

function App() {

  const { token, setToken } = useToken();

  if(!token) {
    return <Login setToken={setToken} />
  }

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/preferences" element={<Preferences />} />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Lưu và đóng tệp. Trình duyệt sẽ tự động làm mới. Sau khi đăng nhập, ứng dụng lập tức chuyển đến trang được bảo vệ vì useState trong Hook tùy chỉnh kích hoạt việc render lại khi trạng thái token được cập nhật:

Hook tùy chỉnh lưu trữ token trong sessionStorage và quản lý trạng thái React. Làm mới trang giữ cho người dùng vẫn đăng nhập vì token vẫn tồn tại trong sessionStorage. Tuy nhiên, mở ứng dụng trong một tab mới sẽ đăng xuất người dùng vì sessionStorage là riêng cho từng tab, dữ liệu không được chia sẻ giữa các tab và sẽ bị xóa khi tab được đóng.

Khi nào nên sử dụng sessionStorage: Sử dụng sessionStorage khi bạn muốn xác thực chỉ trong phiên và sẽ bị xóa khi đóng tab. Điều này an toàn hơn cho máy tính dùng chung.

Khi nào nên sử dụng localStorage: Sử dụng localStorage khi bạn muốn nhận diện người dùng được duy trì qua các tab và phiên trình duyệt. Điều này mang lại trải nghiệm người dùng tốt hơn nhưng yêu cầu người dùng phải đăng xuất rõ ràng trên các máy tính dùng chung.

Sử dụng localStorage để Lưu Dữ liệu Giữa Các Cửa sổ

localStorage tồn tại qua các phiên và tab trình duyệt, không giống như sessionStorage chỉ dành riêng cho từng tab. Điều này có nghĩa là người dùng vẫn đăng nhập khi mở tab mới hoặc khởi động lại trình duyệt.

Cân nhắc về bảo mật: Trên các máy tính dùng chung, các token localStorage vẫn tồn tại ngay cả sau khi đóng trình duyệt. Người dùng tiếp theo có thể truy cập vào ứng dụng mà không cần đăng nhập nếu người dùng trước đó không đăng xuất. Đối với các máy tính dùng chung, hãy xem xét:

  • Thêm cảnh báo hết thời gian phiên
  • Triển khai tính năng “Ghi nhớ đăng nhập” dưới dạng tùy chọn
  • Sử dụng sessionStorage theo mặc định và chỉ sử dụng localStorage khi được yêu cầu rõ ràng
  • Cung cấp chức năng đăng xuất rõ ràng

Đối với các thiết bị cá nhân, localStorage mang lại trải nghiệm người dùng tốt hơn với rủi ro bảo mật tối thiểu khi kết hợp với các biện pháp bảo vệ XSS thích hợp.

Để chuyển sang localStorage, mở useToken.js:

nano src/components/App/useToken.js

Thay tất cả các tham chiếu sessionStorage bằng localStorage. Các phương thức API (getItem, setItem) là giống nhau:

import { useState } from 'react';

export default function useToken() {
  const getToken = () => {
    const tokenString = localStorage.getItem('token');
    if (!tokenString) {
      return null;
    }
    const userToken = JSON.parse(tokenString);
    return userToken?.token;
  };

  const [token, setToken] = useState(getToken());

  const saveToken = userToken => {
    localStorage.setItem('token', JSON.stringify(userToken));
    setToken(userToken.token);
  };

  return {
    setToken: saveToken,
    token
  }
}

Lưu tệp. Trình duyệt sẽ tự động làm mới. Đăng nhập lại (token trước đó nằm trong sessionStorage, không phải localStorage). Sau khi đăng nhập, token sẽ được lưu trong localStorage, vì vậy bạn vẫn đăng nhập khi mở tab mới hoặc khởi động lại trình duyệt.

Bạn đã triển khai lưu trữ token vĩnh viễn sử dụng sessionStorage và localStorage, và tạo một Hook tùy chỉnh useToken kết hợp các thao tác lưu trữ với quản lý trạng thái trong React. Hook này kích hoạt việc render lại khi token thay đổi, đảm bảo giao diện người dùng được cập nhật chính xác. Bạn cũng đã tìm hiểu về những điểm cần cân nhắc giữa sessionStorage (riêng cho từng tab, an toàn hơn) và localStorage (lưu trữ qua các phiên, cải thiện trải nghiệm người dùng).

Bước 4 — Triển khai các tuyến đường được bảo vệ với React Router v6

Các tuyến đường được bảo vệ yêu cầu xác thực trước khi hiển thị. Trong React Router v6, bạn tạo một thành phần bao bọc kiểm tra token và chuyển hướng người dùng chưa xác thực đến trang đăng nhập, đồng thời giữ nguyên đích đến mà họ định truy cập.

Tại sao nên sử dụng thành phần ProtectedRoute: Thay vì kiểm tra xác thực trong mỗi thành phần hoặc sử dụng điều kiện hiển thị trong App.js, một thành phần ProtectedRoute riêng biệt tập trung hóa logic bảo vệ tuyến đường. Điều này giúp dễ dàng thêm các tuyến đường được bảo vệ, xử lý chuyển hướng và duy trì hành vi xác thực nhất quán.

Tạo một tệp mới cho thành phần ProtectedRoute:

nano src/components/ProtectedRoute/ProtectedRoute.js

Bên trong ProtectedRoute.js, nhập Navigate từ react-router-dom và tạo một component kiểm tra token:

import { Navigate } from 'react-router-dom';

export default function ProtectedRoute({ children, token }) {
  if (!token) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

Thành phần ProtectedRoute nhận children (thành phần route cần bảo vệ) và token làm props. Nếu không có token, nó sẽ chuyển hướng đến /login sử dụng Navigate với prop replace để tránh thêm một mục vào lịch sử trình duyệt.

Bây giờ hãy cập nhật App.js để sử dụng ProtectedRoute:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import ProtectedRoute from '../ProtectedRoute/ProtectedRoute';
import useToken from './useToken';

function App() {
  const { token, setToken } = useToken();

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<Login setToken={setToken} />} />
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute token={token}>
                <Dashboard />
              </ProtectedRoute>
            }
          />
          <Route
            path="/preferences"
            element={
              <ProtectedRoute token={token}>
                <Preferences />
              </ProtectedRoute>
            }
          />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Với cách tiếp cận này, những người dùng chưa xác thực khi truy cập /dashboard hoặc /preferences sẽ được chuyển hướng đến /login.

Lưu ý: Trong React Router v6, bạn cũng có thể sử dụng thành phần Outlet cho các tuyến lồng nhau và bảo vệ tuyến đường. Đối với các ứng dụng đơn giản hơn, thành phần bao ProtectedRoute được trình bày ở đây là đủ và dễ hiểu hơn.

Bước 5 — Giải mã JWT Token và Kiểm tra Hết hạn

JWT token chứa thông tin hết hạn trong phần payload của chúng. Bạn có thể giải mã token trên phía khách hàng để kiểm tra xem nó đã hết hạn hay chưa mà không cần gửi yêu cầu đến máy chủ, cải thiện trải nghiệm người dùng bằng cách hiển thị phản hồi ngay lập tức.

Tại sao nên giải mã token trên phía client: Mặc dù bạn luôn nên xác thực token trên server, việc giải mã trên client cho phép bạn kiểm tra thời hạn trước khi thực hiện các cuộc gọi API, hiển thị thông báo lỗi phù hợp và tự động đăng xuất người dùng khi token đã hết hạn.

Đầu tiên, cài đặt một thư viện giải mã JWT. Mặc dù bạn có thể giải mã JWT thủ công (chúng được mã hóa theo base64), sử dụng thư viện sẽ xử lý các trường hợp đặc biệt và cung cấp khả năng xử lý lỗi tốt hơn:

npm install jwt-decode

Cập nhật hook useToken.js của bạn để giải mã và xác thực token:

import { useState } from 'react';
import { jwtDecode } from 'jwt-decode';

export default function useToken() {
  const getToken = () => {
    const tokenString = localStorage.getItem('token');
    if (!tokenString) {
      return null;
    }
    
    const userToken = JSON.parse(tokenString);
    const token = userToken?.token;
    
    if (!token) {
      return null;
    }

    // Decode the token to check expiration
    try {
      const decoded = jwtDecode(token);
      const currentTime = Date.now() / 1000; // Convert to seconds
      
      // If token is expired, remove it and return null
      if (decoded.exp < currentTime) {
        localStorage.removeItem('token');
        return null;
      }
      
      return token;
    } catch (error) {
      // If decoding fails, token is invalid
      localStorage.removeItem('token');
      return null;
    }
  };

  const [token, setToken] = useState(getToken());

  const saveToken = userToken => {
    localStorage.setItem('token', JSON.stringify(userToken));
    setToken(userToken.token);
  };

  const removeToken = () => {
    localStorage.removeItem('token');
    setToken(null);
  };

  return {
    setToken: saveToken,
    token,
    removeToken
  };
}

Hàm getToken hiện nay giải mã JWT và kiểm tra thuộc tính exp (dấu thời gian hết hạn tính bằng giây). Nếu token đã hết hạn hoặc không hợp lệ, nó sẽ xóa token khỏi bộ nhớ và trả về null, thực chất là đăng xuất người dùng.

Cảnh báo: Giải mã token phía client chỉ để cải thiện trải nghiệm người dùng. Luôn xác thực token trên server của bạn. Người dùng ác ý có thể sửa mã phía client hoặc gửi token giả. Không bao giờ tin tưởng việc xác thực phía client cho các quyết định bảo mật.

Bước 6 — Thực hiện đăng xuất và xử lý lỗi

Đăng xuất sẽ xóa token khỏi bộ nhớ và chuyển hướng đến trang đăng nhập. Xử lý lỗi ngăn ứng dụng bị hỏng khi các cuộc gọi API thất bại và cung cấp phản hồi cho người dùng.

Tại sao cần triển khai chức năng đăng xuất: Nếu không có chức năng đăng xuất, người dùng phải tự xóa bộ nhớ trình duyệt hoặc chờ token hết hạn. Nút đăng xuất cung cấp cách rõ ràng để kết thúc phiên và rất cần thiết cho các máy tính dùng chung.

Cập nhật Dashboard của bạn để bao gồm một nút đăng xuất:

import React from 'react';
import { useNavigate } from 'react-router-dom';

export default function Dashboard({ removeToken }) {
  const navigate = useNavigate();

  const handleLogout = () => {
    removeToken();
    navigate('/login');
  };

  return (
    <div>
      <h2>Dashboard</h2>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
}

Cập nhật App.js để truyền removeToken cho các tuyến đường được bảo vệ:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import ProtectedRoute from '../ProtectedRoute/ProtectedRoute';
import useToken from './useToken';

function App() {
  const { token, setToken, removeToken } = useToken();

  return (
    <div className="wrapper">
      <h1>Application</h1>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<Login setToken={setToken} />} />
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute token={token}>
                <Dashboard removeToken={removeToken} />
              </ProtectedRoute>
            }
          />
          <Route
            path="/preferences"
            element={
              <ProtectedRoute token={token}>
                <Preferences />
              </ProtectedRoute>
            }
          />
        </Routes>
      </BrowserRouter>
    </div>
  );
}

export default App;

Bây giờ thêm xử lý lỗi vào thành phần đăng nhập:

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './Login.css';

async function loginUser(credentials) {
  const response = await fetch('http://localhost:8080/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(credentials)
  });

  if (!response.ok) {
    throw new Error(`Login failed: ${response.statusText}`);
  }

  const data = await response.json();
  return data;
}

export default function Login({ setToken }) {
  const [username, setUserName] = useState();
  const [password, setPassword] = useState();
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async e => {
    e.preventDefault();
    setError(null);
    setLoading(true);

    try {
      const token = await loginUser({
        username,
        password
      });
      setToken(token);
    } catch (err) {
      setError(err.message || 'An error occurred during login');
    } finally {
      setLoading(false);
    }
  };

  return(
    <div className="login-wrapper">
      <h1>Please Log In</h1>
      {error && <div className="error-message">{error}</div>}
      <form onSubmit={handleSubmit}>
        <label>
          <p>Username</p>
          <input 
            type="text" 
            onChange={e => setUserName(e.target.value)}
            disabled={loading}
          />
        </label>
        <label>
          <p>Password</p>
          <input 
            type="password" 
            onChange={e => setPassword(e.target.value)}
            disabled={loading}
          />
        </label>
        <div>
          <button type="submit" disabled={loading}>
            {loading ? 'Logging in...' : 'Submit'}
          </button>
        </div>
      </form>
    </div>
  );
}

Login.propTypes = {
  setToken: PropTypes.func.isRequired
};

Thành phần đăng nhập hiện nay xử lý lỗi, hiển thị trạng thái đang tải và cung cấp phản hồi cho người dùng. Việc xử lý lỗi ngăn ứng dụng bị sập khi các cuộc gọi API thất bại và cung cấp cho người dùng thông tin về những gì đã xảy ra sai.

Bước 7 — Sử dụng React Context cho Xác thực

React Context cung cấp một cách để chia sẻ trạng thái xác thực giữa các component mà không cần truyền props qua nhiều cấp. Đối với các ứng dụng lớn hơn, Context có tính mở rộng cao hơn so với việc truyền props qua nhiều lớp component.

Tại sao sử dụng Context cho xác thực: Khi trạng thái xác thực cần được sử dụng ở nhiều thành phần (thanh điều hướng, menu người dùng, các tuyến đường bảo vệ), việc truyền props trở nên phiền phức. Context cung cấp một cách tiếp cận gọn gàng để truy cập trạng thái xác thực ở bất kỳ đâu trong cây thành phần.

Tạo một ngữ cảnh xác thực:

import React, { createContext, useContext, useState } from 'react';
import useToken from '../components/App/useToken';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const { token, setToken, removeToken } = useToken();
  const [user, setUser] = useState(null);

  const login = async (credentials) => {
    // Login logic here
    const response = await fetch('http://localhost:8080/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(credentials)
    });

    if (!response.ok) {
      throw new Error('Login failed');
    }

    const data = await response.json();
    setToken(data);
    // Optionally decode token to set user info
    return data;
  };

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

  const value = {
    token,
    user,
    login,
    logout,
    isAuthenticated: !!token
  };

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

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Bao bọc ứng dụng của bạn bằng AuthProvider:

import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { AuthProvider } from '../../contexts/AuthContext';
import './App.css';
import Dashboard from '../Dashboard/Dashboard';
import Login from '../Login/Login';
import Preferences from '../Preferences/Preferences';
import ProtectedRoute from '../ProtectedRoute/ProtectedRoute';

function App() {
  return (
    <AuthProvider>
      <div className="wrapper">
        <h1>Application</h1>
        <BrowserRouter>
          <Routes>
            <Route path="/login" element={<Login />} />
            <Route
              path="/dashboard"
              element={
                <ProtectedRoute>
                  <Dashboard />
                </ProtectedRoute>
              }
            />
            <Route
              path="/preferences"
              element={
                <ProtectedRoute>
                  <Preferences />
                </ProtectedRoute>
              }
            />
          </Routes>
        </BrowserRouter>
      </div>
    </AuthProvider>
  );
}

export default App;

Cập nhật ProtectedRoute để sử dụng context:

import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

export default function ProtectedRoute({ children }) {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

Bây giờ bất kỳ thành phần nào cũng có thể truy cập trạng thái xác thực bằng useAuth(), không còn cần phải truyền props qua nhiều lớp nữa.

Bước 8 — Hiểu về bảo vệ CSRF cho xác thực dựa trên cookie

Các cuộc tấn công CSRF (Cross-Site Request Forgery) lợi dụng thực tế rằng trình duyệt tự động gửi cookie kèm theo các yêu cầu. Khi sử dụng xác thực dựa trên cookie, bạn phải triển khai biện pháp bảo vệ CSRF để ngăn các trang web độc hại thực hiện các yêu cầu đã xác thực thay cho người dùng.

Tại sao bảo vệ CSRF lại quan trọng: Nếu việc xác thực của bạn dựa vào cookie (thay vì token trong localStorage), một website độc hại có thể đánh lừa người dùng thực hiện các yêu cầu đã xác thực tới API của bạn. Ví dụ, kẻ tấn công có thể nhúng một biểu mẫu gửi tới API của bạn, và nếu người dùng đã đăng nhập, trình duyệt sẽ tự động bao gồm cookie xác thực.

Chiến lược bảo vệ CSRF:

  1. Token CSRF: Tạo một token duy nhất cho mỗi phiên và yêu cầu nó trong các yêu cầu. Token phải khớp với token được lưu trên máy chủ.

  2. Thuộc tính Cookie SameSite: Đặt cookie với SameSite=Strict hoặc SameSite=Lax để ngăn chúng được gửi trong các yêu cầu từ trang web khác.

  3. Xác thực Header Nguồn: Kiểm tra rằng header Origin hoặc Referer khớp với miền của bạn.

  4. Double Submit Cookie: Lưu trữ token CSRF trong cả cookie và trường biểu mẫu, yêu cầu cả hai phải khớp nhau.

Dưới đây là ví dụ về cách triển khai bảo vệ CSRF trên máy chủ Express của bạn:

const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');

const app = express();

app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true // Allow cookies
}));
app.use(cookieParser());
app.use(express.json());

// Generate CSRF token
app.get('/api/csrf-token', (req, res) => {
  const token = crypto.randomBytes(32).toString('hex');
  res.cookie('csrf-token', token, {
    httpOnly: false, // Must be accessible to JavaScript
    sameSite: 'strict',
    secure: process.env.NODE_ENV === 'production'
  });
  res.json({ csrfToken: token });
});

// Verify CSRF token middleware
function verifyCsrfToken(req, res, next) {
  const token = req.headers['x-csrf-token'];
  const cookieToken = req.cookies['csrf-token'];

  if (!token || !cookieToken || token !== cookieToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }

  next();
}

app.post('/login', verifyCsrfToken, (req, res) => {
  // Login logic here
  res.json({ token: 'test123' });
});

app.listen(8080, () => console.log('API is running on http://localhost:8080'));

Trong ứng dụng React của bạn, hãy lấy token CSRF trước khi thực hiện các yêu cầu đã xác thực:

let csrfToken = null;

export async function getCsrfToken() {
  if (!csrfToken) {
    const response = await fetch('http://localhost:8080/api/csrf-token', {
      credentials: 'include' // Include cookies
    });
    const data = await response.json();
    csrfToken = data.csrfToken;
  }
  return csrfToken;
}

export async function loginUser(credentials) {
  const token = await getCsrfToken();
  
  return fetch('http://localhost:8080/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': token
    },
    credentials: 'include', // Include cookies
    body: JSON.stringify(credentials)
  }).then(data => data.json());
}

Lưu ý: Bảo vệ CSRF không cần thiết cho xác thực dựa trên token được lưu trong localStorage vì token phải được bao gồm rõ ràng trong các header của yêu cầu và không được gửi tự động cùng với yêu cầu. Tuy nhiên, nếu bạn sử dụng cookie để xác thực, bảo vệ CSRF là cần thiết vì cookie sẽ được gửi tự động. Bất kể phương pháp xác thực của bạn là gì, luôn triển khai các biện pháp bảo mật thích hợp như HTTPS, kiểm tra đầu vào và giới hạn tốc độ.

Khắc phục sự cố

Token không được giữ sau khi làm mới trang

Vấn đề: Sau khi đăng nhập, làm mới trang sẽ khiến bạn bị đăng xuất.

Giải pháp:

  • Xác minh rằng token được lưu trong localStorage hoặc sessionStorage (kiểm tra DevTools của trình duyệt > Application > Storage)
  • Đảm bảo getToken() chạy khi component được gắn và khởi tạo trạng thái đúng cách
  • Kiểm tra lỗi trong bảng điều khiển trình duyệt có thể ngăn việc lấy token
  • Xác minh định dạng token có khớp với những gì mã của bạn mong đợi (đối tượng có thuộc tính token) không

Lỗi “Không thể đọc thuộc tính ‘token’ của null”

Vấn đề: Xảy ra lỗi khi truy cập userToken.token sau JSON.parse().

Giải pháp: Thêm kiểm tra giá trị null trước khi phân tích:

const tokenString = localStorage.getItem('token');
if (!tokenString) return null;
const userToken = JSON.parse(tokenString);
return userToken?.token;

Biểu mẫu đăng nhập không gửi được

Vấn đề: Bấm nút gửi không kích hoạt yêu cầu đăng nhập.

Giải pháp:

  • Đảm bảo rằng form có onSubmit={handleSubmit} và nút có type="submit"
  • Kiểm tra xem máy chủ API có đang chạy trên cổng đúng (mặc định: 8080) không
  • Xác minh rằng CORS đã được bật trên máy chủ nếu thực hiện các yêu cầu giữa các nguồn
  • Kiểm tra bảng điều khiển trình duyệt để tìm lỗi mạng hoặc vấn đề CORS

Các tuyến được bảo vệ không hoạt động

Vấn đề: Người dùng có thể truy cập các tuyến được bảo vệ mà không cần xác thực.

Giải pháp:

  • Xác minh rằng thành phần ProtectedRoute kiểm tra token trước khi hiển thị
  • Đảm bảo token được truyền vào ProtectedRoute hoặc truy cập thông qua Context
  • Kiểm tra xem các tuyến đường có được bao bọc đúng cách trong App.js không
  • Xác minh cú pháp React Router v6 (thuộc tính element, không phải children)

Token hết hạn trong khi người dùng đang hoạt động

Vấn đề: Người dùng bị đăng xuất bất ngờ trong quá trình sử dụng.

Giải pháp:

  • Triển khai logic làm mới token sử dụng token làm mới
  • Kiểm tra thời hạn token trước khi thực hiện các cuộc gọi API
  • Thêm làm mới token tự động khi gần hết hạn
  • Xem xét kéo dài thời gian hết hạn token cho người dùng đang hoạt động

localStorage bị vô hiệu hóa hoặc vượt quá hạn mức

Vấn đề: Token không được lưu trong chế độ riêng tư/ẩn danh hoặc bộ nhớ đầy.

Giải pháp:

  • Kiểm tra xem localStorage có sẵn hay không: if (typeof(Storage) !== "undefined")
  • Xử lý QuotaExceededError khi thiết lập các mục
  • Quay lại sử dụng sessionStorage hoặc bộ nhớ trong
  • Xóa các mã cũ trước khi lưu mã mới
  • Thông báo cho người dùng nếu bộ nhớ không khả dụng

Nhiều tab không đồng bộ trạng thái xác thực

Vấn đề: Đăng xuất ở một tab không đăng xuất ở các tab khác.

Giải pháp: Nghe các sự kiện storage để đồng bộ trạng thái giữa các tab:

useEffect(() => {
  const handleStorageChange = (e) => {
    if (e.key === 'token') {
      // Update auth state when token changes in another tab
      setToken(e.newValue ? JSON.parse(e.newValue) : null);
    }
  };
  window.addEventListener('storage', handleStorageChange);
  return () => window.removeEventListener('storage', handleStorageChange);
}, []);

API Trả Về Định Dạng Token Khác

Vấn đề: API của bạn trả về { accessToken: ''...'' } thay vì { token: ''...'' }.

Giải pháp: Cập nhật mã để phù hợp với định dạng phản hồi của API của bạn:

// If API returns { accessToken: '...' }
const token = userToken?.accessToken;

// Or if API returns { data: { token: '...' } }
const token = userToken?.data?.token;

Lỗi 401 Chưa được ủy quyền

Vấn đề: API trả về 401 ngay cả khi có token hợp lệ.

Giải pháp:

  • Xác minh token được gửi trong phần header của yêu cầu: Authorization: Bearer $ {token}
  • Kiểm tra hết hạn token (giải mã JWT để xem giá trị exp)
  • Đảm bảo định dạng token phù hợp với yêu cầu của máy chủ
  • Xác minh máy chủ xác thực các token đúng cách
  • Kiểm tra xem token có cần được làm mới hay không

Câu hỏi thường gặp

1. Làm thế nào để tôi bảo vệ các tuyến đường trong React?

Sử dụng một component wrapper ProtectedRoute để kiểm tra xác thực trước khi render. Trong React Router v6, bọc các route bảo vệ bằng một component kiểm tra xem token có tồn tại hay không. Nếu không tìm thấy token, chuyển hướng đến trang đăng nhập bằng Navigate:

function ProtectedRoute({ children, token }) {
  if (!token) {
    return <Navigate to="/login" replace />;
  }
  return children;
}

Sau đó sử dụng nó trong các tuyến đường của bạn:

<Route
  path="/dashboard"
  element={
    <ProtectedRoute token={token}>
      <Dashboard />
    </ProtectedRoute>
  }
/>

2. Tôi nên lưu trữ các token xác thực ở đâu?

Lưu token trong localStorage để giữ dữ liệu qua các phiên làm việc, hoặc sessionStorage cho các phiên làm việc riêng của từng tab. Cả hai đều có thể được truy cập bởi JavaScript, làm chúng dễ bị tấn công XSS, vì vậy luôn luôn lọc dữ liệu đầu vào từ người dùng và sử dụng HTTPS.

  • localStorage: Lưu trữ qua các phiên và tab trình duyệt. Dùng cho chức năng “ghi nhớ tôi”.
  • sessionStorage: Được xóa khi tab đóng lại. Sử dụng để xác thực chỉ trong phiên, an toàn hơn.
  • Cookies: Tự động gửi kèm với các yêu cầu, cần có bảo vệ CSRF. Sử dụng với cờ httpOnly để chỉ truy cập phía máy chủ.

Đối với các ứng dụng sản xuất, hãy cân nhắc sử dụng cookie bảo mật, httpOnly được quản lý bởi backend của bạn, vì chúng ít bị tấn công XSS hơn.

3. localStorage có an toàn để dùng cho xác thực không?

localStorage dễ bị tấn công XSS nhưng vẫn chấp nhận được cho nhiều ứng dụng khi kết hợp với các biện pháp bảo mật phù hợp. Bất kỳ mã JavaScript nào chạy trên miền của bạn đều có thể truy cập localStorage, vì vậy nếu kẻ tấn công chèn các đoạn mã độc hại, chúng có thể đánh cắp token.

Chiến lược giảm thiểu:

  • Làm sạch tất cả dữ liệu nhập từ người dùng để ngăn chặn XSS
  • Sử dụng các tiêu đề Chính sách Bảo mật Nội dung (CSP)
  • Thực hiện hết hạn token và làm mới token
  • Sử dụng HTTPS để ngăn chặn các cuộc tấn công man-in-the-middle
  • Xem xét sử dụng cookie httpOnly cho các ứng dụng nhạy cảm

Đối với các ứng dụng có độ bảo mật cao (ngân hàng, chăm sóc sức khỏe), nên ưu tiên sử dụng cookie httpOnly kèm bảo vệ CSRF, vì chúng không thể truy cập được bởi JavaScript.

4. Ứng dụng React có cần OAuth không?

OAuth là bắt buộc khi xác thực với các nhà cung cấp bên thứ ba (Google, GitHub, Facebook) nhưng không bắt buộc đối với xác thực tùy chỉnh. Nếu bạn đang xây dựng hệ thống xác thực riêng bằng tên đăng nhập/mật khẩu, bạn có thể sử dụng xác thực dựa trên token như hướng dẫn trong bài hướng dẫn này.

Sử dụng OAuth khi:

  • Người dùng nên đăng nhập bằng tài khoản mạng xã hội
  • Bạn muốn ủy quyền xác thực cho một nhà cung cấp đáng tin cậy
  • Bạn cần truy cập các API bên thứ ba thay mặt cho người dùng

Sử dụng xác thực tùy chỉnh khi:

  • Bạn quản lý cơ sở dữ liệu người dùng
  • Bạn cần kiểm soát hoàn toàn quá trình xác thực
  • Bạn muốn tránh các phụ thuộc từ bên thứ ba

Nhiều ứng dụng sử dụng phương pháp kết hợp: OAuth cho đăng nhập xã hội và xác thực tùy chỉnh cho email/mật khẩu.

5. Tôi phải xử lý việc hết hạn token như thế nào?

Giải mã token JWT trên phía client để kiểm tra thời hạn, sau đó làm mới hoặc đăng xuất người dùng. Sử dụng một thư viện như jwt-decode để đọc trường exp:

import { jwtDecode } from 'jwt-decode';

const decoded = jwtDecode(token);
const currentTime = Date.now() / 1000;

if (decoded.exp < currentTime) {
  // Token expired, remove it and redirect to login
  localStorage.removeItem('token');
  navigate('/login');
}

Đối với môi trường sản xuất, triển khai token làm mới: token truy cập có thời hạn ngắn (15-30 phút) và token làm mới có thời hạn dài hơn (7-30 ngày) có thể được sử dụng để lấy token truy cập mới mà không cần xác thực lại.

6. Làm thế nào để tôi triển khai chức năng "Ghi nhớ tôi"?

Lưu token trong localStorage cho các phiên làm việc lâu dài và trong sessionStorage cho các phiên làm việc tạm thời. Thêm một ô chọn vào biểu mẫu đăng nhập của bạn:

const [rememberMe, setRememberMe] = useState(false);

const handleSubmit = async (e) => {
  e.preventDefault();
  const token = await loginUser(credentials);
  
  if (rememberMe) {
    localStorage.setItem('token', JSON.stringify(token));
  } else {
    sessionStorage.setItem('token', JSON.stringify(token));
  }
};

Khi rememberMe được chọn, token sẽ được giữ lại qua các phiên trình duyệt. Ngược lại, nó sẽ bị xóa khi tab đóng lại.

Kết luận

Xác thực trong các ứng dụng React yêu cầu cân bằng giữa bảo mật, trải nghiệm người dùng và khả năng bảo trì. Bằng cách tập trung logic xác thực vào các hook tùy chỉnh hoặc React Context, triển khai các tuyến đường được bảo vệ với React Router v6, và lựa chọn các chiến lược lưu trữ token phù hợp, bạn có thể xây dựng các luồng xác thực an toàn và thân thiện với người dùng.

Những nguyên tắc chính cần ghi nhớ:

  • Lưu token trong bộ nhớ trình duyệt (localStorage hoặc sessionStorage) để giữ dữ liệu khi làm mới trang
  • Sử dụng các thành phần định tuyến được bảo vệ để thực thi các yêu cầu xác thực
  • Giải mã token JWT trên client để kiểm tra thời hạn, nhưng luôn xác thực trên server
  • Thực hiện xử lý lỗi và trạng thái tải để cải thiện trải nghiệm người dùng
  • Cân nhắc bảo vệ CSRF khi sử dụng xác thực dựa trên cookie
  • Sử dụng React Context cho trạng thái xác thực trong các ứng dụng lớn để tránh việc truyền props qua nhiều cấp

Mỗi giải pháp lưu trữ và phương pháp xác thực đều có những mặt lợi và hại. localStorage mang lại sự tiện lợi nhưng cần có biện pháp bảo vệ chống XSS. sessionStorage bảo mật hơn nhưng kém tiện lợi hơn. Cookie cần bảo vệ chống CSRF nhưng ít bị tấn công XSS hơn. Hãy chọn dựa trên yêu cầu bảo mật và mục tiêu trải nghiệm người dùng của bạn.

Khi ứng dụng của bạn phát triển, hãy cân nhắc triển khai token làm mới, xác thực đa yếu tố và các tính năng quản lý phiên. Đối với các ứng dụng sản xuất, luôn sử dụng HTTPS, triển khai giới hạn tốc độ và tuân theo các thực hành bảo mật tốt nhất.

Nếu bạn muốn đọc thêm các hướng dẫn về React, hãy xem trang Chủ đề React của chúng tôi, hoặc khám phá các hướng dẫn liên quan như Cách Xây Dựng Ứng Dụng To-Do Sử Dụng Django và React và Cách Triển Khai Ứng Dụng React với Nginx trên Ubuntu.

Cảm ơn bạn đã học cùng cộng đồng Linux-Console.net.