Bài 19.3: Protected routes trong TypeScript

Bài 19.3: Protected routes trong TypeScript
Chào mừng trở lại với series lập trình Web Front-end của chúng ta! Sau khi đã làm quen với TypeScript và cách tích hợp nó vào các framework như React, hôm nay chúng ta sẽ giải quyết một vấn đề thiết yếu trong phát triển ứng dụng Web: bảo vệ các tuyến đường (routes).
Hãy tưởng tượng ứng dụng của bạn có trang quản trị, trang hồ sơ người dùng, hoặc nội dung trả phí. Bạn chắc chắn không muốn bất kỳ ai cũng có thể truy cập vào những khu vực này mà không thông qua quá trình xác thực (đăng nhập) hoặc có đủ quyền hạn, đúng không? Đó chính là lúc Protected routes phát huy tác dụng!
Protected Routes là gì và tại sao lại cần chúng?
Hiểu một cách đơn giản, Protected route là một tuyến đường (URL) trong ứng dụng Web của bạn mà chỉ có thể truy cập được bởi người dùng đã xác thực (authenticated) hoặc đôi khi là có quyền hạn (authorized) cụ thể.
Mục đích chính là tạo ra một lớp bảo vệ quanh các khu vực nhạy cảm hoặc cá nhân hóa của ứng dụng. Nếu một người dùng chưa đăng nhập cố gắng truy cập vào /admin
hoặc /profile
, chúng ta cần chặn họ lại và thường là chuyển hướng họ đến trang đăng nhập.
Tại sao lại dùng TypeScript cho việc này?
TypeScript mang lại sự an toàn kiểu dữ liệu (type safety). Khi làm việc với trạng thái xác thực của người dùng (ví dụ: isAuthenticated: boolean
, user: User | null
, user.roles: string[]
), việc sử dụng TypeScript giúp bạn:
- Ngăn chặn lỗi runtime: Tránh các lỗi phổ biến như truy cập thuộc tính trên một đối tượng
null
hoặcundefined
(ví dụ: cố gắng đọcuser.roles
khiuser
lànull
). - Tăng khả năng đọc và bảo trì: Mã nguồn trở nên rõ ràng hơn về loại dữ liệu mà nó đang xử lý.
- Cải thiện trải nghiệm phát triển: IDE có thể cung cấp gợi ý và cảnh báo sớm về các lỗi liên quan đến kiểu dữ liệu.
Việc xây dựng Protected routes với TypeScript giúp chúng ta tạo ra một lớp bảo vệ đáng tin cậy hơn và dễ quản lý hơn ở phía Front-end. Tuy nhiên, luôn nhớ rằng bảo vệ Front-end chỉ là lớp phòng thủ đầu tiên. Việc kiểm tra quyền truy cập phía Back-end khi xử lý các yêu cầu dữ liệu là bắt buộc để đảm bảo an toàn thực sự.
Cơ chế hoạt động cơ bản
Khi một người dùng yêu cầu truy cập một Protected route:
- Ứng dụng Front-end sẽ kiểm tra trạng thái xác thực của người dùng hiện tại (ví dụ: thông qua một Context, Redux store, hoặc một hook quản lý state).
- Nếu người dùng chưa xác thực: Ứng dụng sẽ chặn truy cập và chuyển hướng họ đến trang đăng nhập hoặc một trang khác (ví dụ: trang chủ).
- Nếu người dùng đã xác thực (và đôi khi kiểm tra thêm quyền hạn): Ứng dụng sẽ cho phép họ truy cập và hiển thị nội dung của route đó.
Các phương pháp triển khai (với TypeScript)
Trong các ứng dụng Front-end hiện đại sử dụng React (hoặc Next.js), chúng ta có nhiều cách để triển khai Protected routes. Dưới đây là một số phương pháp phổ biến sử dụng TypeScript:
1. Sử dụng Wrapper Component (với react-router-dom
)
Đây là một cách tiếp cận phổ biến, tạo ra một component đặc biệt đóng vai trò là "người gác cổng" cho các routes.
Giả sử bạn đang sử dụng react-router-dom
v6+. Bạn có thể tạo một component ProtectedRoute
như sau:
// src/components/ProtectedRoute.tsx
import React, { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth'; // Giả sử bạn có hook quản lý auth
interface ProtectedRouteProps {
children: ReactNode;
// Thêm các props khác nếu cần kiểm tra quyền hạn cụ thể
// requiredRoles?: string[];
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children /*, requiredRoles */ }) => {
const { isAuthenticated, user, loading } = useAuth(); // Lấy trạng thái auth từ hook
const location = useLocation();
// Xử lý trạng thái đang tải (nếu việc kiểm tra auth là async)
if (loading) {
return <div>Đang kiểm tra xác thực...</div>; // Hoặc một spinner
}
// Kiểm tra xác thực
if (!isAuthenticated) {
// Nếu chưa xác thực, chuyển hướng đến trang đăng nhập
// Lưu lại đường dẫn hiện tại để quay lại sau khi đăng nhập
return <Navigate to="/login" state={{ from: location }} replace />;
}
// --- Có thể thêm kiểm tra quyền hạn tại đây ---
// if (requiredRoles && user && !requiredRoles.some(role => user.roles.includes(role))) {
// // Nếu không có quyền, chuyển hướng đến trang khác (ví dụ: /unauthorized hoặc /dashboard)
// return <Navigate to="/unauthorized" replace />;
// }
// ---------------------------------------------
// Nếu đã xác thực và có quyền (nếu có kiểm tra), cho phép truy cập
return <>{children}</>;
};
export default ProtectedRoute;
Giải thích:
- Component
ProtectedRoute
nhận propchildren
, đại diện cho các component con (trang) mà nó bao bọc. - Nó sử dụng hook
useAuth()
(giả định bạn đã xây dựng hook này để quản lý trạng thái đăng nhập) để lấyisAuthenticated
, thông tinuser
, và trạng tháiloading
. Việc sử dụng hook giúp tách biệt logic auth ra khỏi component hiển thị. - Nếu
loading
làtrue
, nó hiển thị thông báo đang tải. - Nếu
!isAuthenticated
, nó sử dụng component<Navigate />
củareact-router-dom
để chuyển hướng người dùng đến/login
.state={{ from: location }}
giúp lưu lại đường dẫn gốc mà người dùng muốn truy cập, để sau khi đăng nhập thành công có thể chuyển hướng họ quay lại đó.replace
giúp thay thế entry hiện tại trong lịch sử trình duyệt, tránh quay lại trang bị chặn bằng nút back. - Nếu đã xác thực, nó render
children
, tức là component trang được bảo vệ. - Phần code comment (
requiredRoles
) minh họa cách bạn có thể mở rộng component này để kiểm tra cả quyền hạn dựa trên vai trò (user.roles
).
Cách sử dụng trong cấu hình routes:
Trong file cấu hình routes của bạn (App.tsx
hoặc Router.tsx
):
// src/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import ProfilePage from './pages/ProfilePage';
import AdminPage from './pages/AdminPage';
import ProtectedRoute from './components/ProtectedRoute';
// Import các component trang khác...
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
{/* Các Protected routes */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
{/* Ví dụ Protected route yêu cầu quyền admin */}
{/* <Route
path="/admin"
element={
<ProtectedRoute requiredRoles={['admin']}>
<AdminPage />
</ProtectedRoute>
}
/> */}
{/* Các routes công khai khác */}
{/* <Route path="/about" element={<AboutPage />} /> */}
{/* <Route path="*" element={<NotFoundPage />} /> */}
</Routes>
</Router>
);
}
export default App;
Giải thích:
- Thay vì render trực tiếp component
DashboardPage
hayProfilePage
, chúng ta bọc chúng trong<ProtectedRoute>
. - Khi
react-router-dom
khớp với đường dẫn/dashboard
, nó sẽ renderProtectedRoute
. ComponentProtectedRoute
sau đó sẽ thực hiện logic kiểm tra xác thực và quyết định renderDashboardPage
hay chuyển hướng đi.
2. Sử dụng Hooks (Kết hợp với component hoặc trong logic route)
Cách tiếp cận hiện đại hơn trong React là sử dụng custom hooks. Bạn có thể có một hook như useRequireAuth
thực hiện việc kiểm tra và chuyển hướng.
// src/hooks/useRequireAuth.ts
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './useAuth'; // Lại sử dụng hook quản lý auth
interface RequireAuthOptions {
redirectPath?: string; // Đường dẫn chuyển hướng khi chưa authenticated
// Thêm các tùy chọn khác, ví dụ: requiredRoles?: string[];
}
const useRequireAuth = (options: RequireAuthOptions = {}) => {
const { redirectPath = '/login' /* , requiredRoles */ } = options;
const { isAuthenticated, user, loading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
// Chờ cho trạng thái loading hoàn thành
if (loading) {
return;
}
// Kiểm tra xác thực
if (!isAuthenticated) {
console.log('Chưa xác thực, chuyển hướng...');
navigate(redirectPath, { state: { from: location }, replace: true });
}
// --- Có thể thêm kiểm tra quyền hạn tại đây ---
// if (isAuthenticated && requiredRoles && user && !requiredRoles.some(role => user.roles.includes(role))) {
// console.log('Không đủ quyền, chuyển hướng...');
// navigate('/unauthorized', { replace: true }); // Chuyển hướng đến trang lỗi quyền
// }
// ---------------------------------------------
}, [isAuthenticated, loading, navigate, location, redirectPath /* , user, requiredRoles */]); // Dependencies
// Hook này không trả về UI, chỉ xử lý logic side effect (chuyển hướng)
// Có thể trả về trạng thái auth để component sử dụng
return { isAuthenticated, user, loading };
};
export default useRequireAuth;
Giải thích:
- Hook
useRequireAuth
sử dụnguseEffect
để thực hiện logic kiểm tra mỗi khi các dependencies (isAuthenticated
,loading
, v.v.) thay đổi. - Nó sử dụng
useNavigate
vàuseLocation
củareact-router-dom
để điều khiển việc chuyển hướng. - Logic kiểm tra xác thực và chuyển hướng tương tự như trong Wrapper Component.
- Việc kiểm tra diễn ra trong
useEffect
, đảm bảo nó chạy sau khi component đã render lần đầu hoặc khi trạng thái auth thay đổi.
Cách sử dụng hook trong component:
Bạn gọi hook này ngay trong component trang cần bảo vệ:
// src/pages/DashboardPage.tsx
import React from 'react';
import useRequireAuth from '../hooks/useRequireAuth';
const DashboardPage: React.FC = () => {
// Sử dụng hook để kiểm tra auth và tự động chuyển hướng nếu cần
const { isAuthenticated, user, loading } = useRequireAuth({ redirectPath: '/login' });
// Trong khi hook đang xử lý hoặc chưa authenticated, có thể hiển thị loading hoặc null
if (loading || !isAuthenticated) {
return <div>Đang tải hoặc không có quyền...</div>; // Hoặc null/spinner trong lúc chuyển hướng
}
// Nếu đã authenticated, hiển thị nội dung trang
return (
<div>
<h1>Chào mừng đến Dashboard!</h1>
{user && <p>Xin chào, {user.name}!</p>}
{/* Nội dung dashboard */}
</div>
);
};
export default DashboardPage;
Ưu điểm của Hook:
- Logic bảo vệ nằm trong component hoặc hook riêng biệt, không cần bọc routes trong file cấu hình chính (trừ khi bạn muốn logic chuyển hướng trước khi component được render hoàn toàn, lúc đó cách Wrapper Component hoặc kết hợp cả hai sẽ phù hợp hơn).
- Tái sử dụng logic kiểm tra auth/quyền hạn dễ dàng trong nhiều component khác nhau, không chỉ ở cấp độ route.
3. Triển khai trong Next.js (Server-Side và Middleware)
Next.js cung cấp các phương pháp mạnh mẽ hơn cho Protected routes, kết hợp cả Front-end và Back-end:
getServerSideProps
: Bạn có thể kiểm tra trạng thái xác thực (thường thông qua cookie hoặc token) trên server trước khi render trang. Nếu người dùng chưa xác thực, bạn có thể trả về một redirect response.// src/pages/profile.tsx import { GetServerSideProps } from 'next'; import { isAuthenticatedUser } from '../utils/auth'; // Hàm kiểm tra auth server-side interface ProfilePageProps { // ... props dữ liệu người dùng nếu có } const ProfilePage: React.FC<ProfilePageProps> = (props) => { // Trang hiển thị nội dung profile return ( <div> <h1>Trang Hồ sơ</h1> {/* Hiển thị dữ liệu từ props */} </div> ); }; export const getServerSideProps: GetServerSideProps = async (context) => { const { req, res } = context; // Giả sử isAuthenticatedUser đọc cookie hoặc header const isAuthenticated = await isAuthenticatedUser(req); if (!isAuthenticated) { // Chuyển hướng đến trang đăng nhập trên server res.setHeader('Location', '/login'); res.statusCode = 302; res.end(); return { props: {} }; // Trả về props rỗng } // Nếu authenticated, có thể fetch dữ liệu người dùng tại đây và truyền vào props // const userData = await fetchUserData(req); return { props: { // userData: userData }, }; }; export default ProfilePage;
Giải thích: Logic kiểm tra và chuyển hướng xảy ra trước khi trang được gửi đến trình duyệt. Điều này an toàn hơn vì nội dung trang nhạy cảm không bao giờ rời khỏi server cho người dùng chưa xác thực.
Middleware (Next.js 12+): Next.js Middleware cho phép bạn chạy code trước khi yêu cầu được hoàn thành cho một nhóm routes. Đây là nơi lý tưởng để đặt logic kiểm tra xác thực và chuyển hướng ở mức độ toàn cục hoặc theo pattern URL.
// middleware.ts (đặt ở thư mục gốc hoặc src) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; // Giả sử bạn có hàm kiểm tra token trong cookie hoặc header // Đây là logic đơn giản, thực tế phức tạp hơn nhiều function isAuthenticated(req: NextRequest): boolean { const token = req.cookies.get('auth_token'); return !!token; // Giả sử chỉ cần có token là authenticated } // Danh sách các path cần bảo vệ const protectedPaths = ['/dashboard', '/profile', '/admin']; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Kiểm tra xem path hiện tại có cần bảo vệ không const isProtected = protectedPaths.some(path => pathname.startsWith(path)); if (isProtected) { // Kiểm tra xem người dùng đã authenticated chưa const authenticated = isAuthenticated(request); if (!authenticated) { // Nếu chưa, chuyển hướng đến trang login const loginUrl = new URL('/login', request.url); // Có thể thêm query param 'from' để redirect lại sau login loginUrl.searchParams.set('from', pathname); return NextResponse.redirect(loginUrl); } // --- Có thể thêm kiểm tra quyền hạn tại đây --- // Lấy thông tin user/roles từ token (cần giải mã token) // if (pathname.startsWith('/admin') && !userHasRole(request, 'admin')) { // return NextResponse.redirect(new URL('/unauthorized', request.url)); // } // --------------------------------------------- } // Cho phép request tiếp tục nếu không phải protected hoặc đã authenticated/authorized return NextResponse.next(); } // Cấu hình matcher để middleware chỉ chạy trên các path cần thiết // Giúp cải thiện hiệu suất export const config = { matcher: [ '/dashboard/:path*', // Bảo vệ /dashboard và các path con '/profile', '/admin/:path*', /* Thêm các path khác */ ], };
Giải thích: Middleware chạy trước khi request tới page components. Nó là nơi trung tâm để xử lý logic chuyển hướng dựa trên xác thực/quyền hạn cho nhiều routes cùng lúc. Nó hoạt động ở rìa của ứng dụng (
Edge Runtime
), rất hiệu quả.
Việc sử dụng TypeScript trong tất cả các phương pháp trên giúp bạn định nghĩa rõ ràng kiểu dữ liệu cho trạng thái xác thực (User | null
), các prop truyền vào (requiredRoles: string[]
), và đảm bảo rằng bạn đang xử lý các giá trị này một cách an toàn.
Comments