Bài 31.3: Protecting API routes trong TypeScript

Khi xây dựng các ứng dụng web hiện đại, API routes đóng vai trò như "cửa ngõ" để ứng dụng frontend hoặc các dịch vụ khác giao tiếp với backend, truy xuất và thao tác dữ liệu. Tuy nhiên, việc để các cửa ngõ này mở toang mà không có bất kỳ biện pháp bảo vệ nào là cực kỳ nguy hiểm. Dữ liệu nhạy cảm có thể bị lộ, các hành động trái phép có thể được thực hiện, và hệ thống của bạn có thể bị tấn công.

Bài học này sẽ đi sâu vào việc làm thế nào để xây dựng "tuyến phòng thủ" vững chắc cho các API routes của bạn bằng TypeScript, đảm bảo chỉ những người dùng được phép mới có thể truy cập vào các tài nguyên nhất định. TypeScript, với hệ thống kiểu dữ liệu mạnh mẽ, còn giúp chúng ta viết code bảo mật một cách có cấu trúcít lỗi hơn.

Tại sao cần bảo vệ API routes?

Hãy tưởng tượng API của bạn xử lý thông tin người dùng, đơn hàng, hay các dữ liệu kinh doanh quan trọng. Nếu không được bảo vệ:

  1. Lộ dữ liệu: Bất kỳ ai cũng có thể gửi yêu cầu đến API và lấy đi thông tin mà họ không có quyền truy cập.
  2. Thao tác trái phép: Kẻ tấn công có thể tạo, cập nhật, hoặc xóa dữ liệu.
  3. Tấn công từ chối dịch vụ (DoS/DDoS): Kẻ tấn công có thể spam API của bạn với hàng triệu yêu cầu, làm sập server hoặc làm chậm ứng dụng.
  4. Lạm dụng tài nguyên: Các bot có thể tận dụng API để thực hiện các hành vi không mong muốn.

Việc bảo vệ API là một phần thiết yếu của bất kỳ ứng dụng web nào.

Các Khái Niệm Cốt Lõi: Xác thực và Ủy quyền

Trước khi đi vào chi tiết kỹ thuật, chúng ta cần hiểu rõ hai khái niệm cơ bản:

  1. Xác thực (Authentication): Trả lời câu hỏi "Bạn là ai?". Quá trình này xác minh danh tính của người gửi yêu cầu. Ví dụ: Đăng nhập bằng email/mật khẩu, sử dụng API key, sử dụng token.
  2. Ủy quyền (Authorization): Trả lời câu hỏi "Bạn có quyền làm gì?". Sau khi đã xác thực danh tính, quá trình này kiểm tra xem người dùng đó có quyền thực hiện hành động cụ thể mà họ yêu cầu hay không. Ví dụ: Chỉ admin mới có quyền xóa bài viết, người dùng thường chỉ được xem profile của chính mình.

Thông thường, quá trình bảo vệ API bắt đầu bằng xác thực, sau đó mới đến ủy quyền.

Các Phương Pháp Bảo Vệ API routes trong TypeScript

Trong môi trường TypeScript (thường dùng với Node.js, Express, Next.js API routes, ...), chúng ta có thể triển khai bảo mật bằng cách sử dụng middleware hoặc các logic kiểm tra ngay tại đầu hàm xử lý request. Middleware là các hàm được chạy trước khi request đến được hàm xử lý chính (route handler).

1. Sử dụng API Keys (Đối với ứng dụng server-to-server hoặc Public API)

API Keys là phương pháp đơn giản nhất. Client gửi một khóa bí mật (API Key) trong header hoặc query parameter của request. Server kiểm tra xem khóa này có hợp lệ hay không. Phương pháp này thường dùng cho các API công cộng có rate limiting, hoặc giao tiếp giữa các server nội bộ, không phù hợp để xác định danh tính người dùng cuối.

Ví dụ Middleware kiểm tra API Key (giống Express):

// src/middleware/requireApiKey.ts
import { Request, Response, NextFunction } from 'express'; // Giả định dùng Express hoặc framework tương tự

// Lưu API Key một cách bảo mật, ví dụ trong biến môi trường
const MY_SECRET_API_KEY = process.env.MY_SECRET_API_KEY;

export const requireApiKey = (req: Request, res: Response, next: NextFunction) => {
  // Lấy API Key từ header X-API-Key (quy ước phổ biến)
  const apiKey = req.headers['x-api-key'];

  // Kiểm tra xem API Key có tồn tại và có khớp không
  if (!apiKey || apiKey !== MY_SECRET_API_KEY) {
    // Trả về lỗi 401 Unauthorized nếu không hợp lệ
    return res.status(401).json({ message: 'Unauthorized: Missing or invalid API Key' });
  }

  // Nếu hợp lệ, chuyển request đến middleware tiếp theo hoặc route handler
  next();
};
  • Giải thích:

    • Middleware requireApiKey nhận các tham số req, res, next giống như middleware thông thường.
    • Chúng ta truy cập header x-api-key từ đối tượng req.headers.
    • So sánh nó với MY_SECRET_API_KEY được lưu trữ bảo mật (biến môi trường là cách tốt).
    • Nếu không khớp, trả về status code 401 Unauthorized và một message lỗi, sau đó return để kết thúc xử lý và không gọi next().
    • Nếu khớp, gọi next() để cho phép request đi tiếp.
  • Cách sử dụng (ví dụ với Express):

    import express from 'express';
    import { requireApiKey } from './middleware/requireApiKey';
    
    const app = express();
    
    // Áp dụng middleware cho một route cụ thể
    app.get('/api/public-data', requireApiKey, (req, res) => {
      // Logic xử lý khi API Key hợp lệ
      res.json({ data: 'This is sensitive public data.' });
    });
    
    // Route không cần API Key
    app.get('/api/status', (req, res) => {
        res.json({ status: 'ok' });
    });
    
    app.listen(3000, () => console.log('Server running on port 3000'));
    

    Chúng ta chỉ đơn giản truyền requireApiKey vào giữa đường dẫn ('/api/public-data') và hàm xử lý cuối cùng.

2. Sử dụng Token (Phổ biến cho người dùng cuối - JWT là ví dụ)

Đây là phương pháp phổ biến nhất cho các ứng dụng SPA (Single Page Application) hoặc mobile. Sau khi người dùng đăng nhập thành công, server tạo và gửi về một token (ví dụ: JSON Web Token - JWT). Client lưu token này (ví dụ: trong Local Storage hoặc Cookie) và gửi kèm trong header Authorization (Bearer <token>) của mỗi request đến các API cần bảo vệ. Server sẽ xác minh tính hợp lệ của token đó.

Ví dụ Middleware xác thực JWT (giống Express):

// src/middleware/requireAuth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; // Cần cài đặt: npm install jsonwebtoken @types/jsonwebtoken

// Khóa bí mật để ký và xác minh JWT - LƯU TRỮ BẢO MẬT!
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-replace-me'; // Sử dụng biến môi trường là tốt nhất

// Mở rộng kiểu Request để thêm thông tin người dùng sau khi xác thực
interface AuthenticatedRequest extends Request {
  user?: { // Định nghĩa kiểu dữ liệu cho user object được đính kèm vào request
    id: string;
    email: string;
    // Có thể thêm các thông tin khác từ payload của JWT
    role?: string;
  };
}

export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
  // Lấy header Authorization
  const authHeader = req.headers['authorization'];
  // Header có dạng "Bearer TOKEN", ta tách lấy TOKEN
  const token = authHeader && authHeader.split(' ')[1];

  // Nếu không có token trong header
  if (token == null) {
    return res.status(401).json({ message: 'Unauthorized: No token provided' });
  }

  // Xác minh token
  jwt.verify(token, JWT_SECRET, (err, decodedPayload) => {
    // Nếu token không hợp lệ (hết hạn, sai chữ ký, ...)
    if (err) {
      return res.status(403).json({ message: 'Forbidden: Invalid token' });
    }

    // Nếu token hợp lệ, decodedPayload chứa thông tin mà bạn đã đưa vào khi ký token
    // Ép kiểu decodedPayload sang kiểu dữ liệu user mong muốn
    req.user = decodedPayload as { id: string; email: string; role?: string };

    // Chuyển request đến middleware tiếp theo hoặc route handler
    next();
  });
};
  • Giải thích:

    • Middleware này sử dụng thư viện jsonwebtoken để làm việc với JWT.
    • Nó lấy token từ header Authorization: Bearer <token>.
    • Sử dụng jwt.verify để kiểm tra token. Hàm callback sẽ nhận lỗi (err) nếu token không hợp lệ hoặc payload đã giải mã (decodedPayload) nếu hợp lệ.
    • Nếu có lỗi xác minh, trả về 403 Forbidden. Lý do dùng 403 thay vì 401 ở đây là request cung cấp thông tin xác thực (token), nhưng thông tin đó không hợp lệ hoặc không đủ (token hết hạn, bị thay đổi).
    • Nếu thành công, chúng ta đính kèm thông tin người dùng từ decodedPayload vào đối tượng req (qua interface AuthenticatedRequest) để các middleware hoặc route handler phía sau có thể sử dụng thông tin này.
    • next() được gọi để tiếp tục xử lý.
  • Cách sử dụng (ví dụ với Express):

    import express from 'express';
    import { requireAuth } from './middleware/requireAuth';
    
    const app = express();
    app.use(express.json()); // Để đọc body của POST request
    
    // Route đăng nhập (không cần bảo vệ)
    app.post('/login', (req, res) => {
      // Logic kiểm tra email/mật khẩu...
      // Nếu thành công:
      const user = { id: 'user123', email: 'test@example.com', role: 'editor' };
      const token = jwt.sign(user, process.env.JWT_SECRET || 'fallback-secret-replace-me', { expiresIn: '1h' });
      res.json({ token });
    });
    
    // Route lấy thông tin profile - Yêu cầu xác thực
    app.get('/profile', requireAuth, (req: AuthenticatedRequest, res) => {
      // Ở đây, req.user đã có thông tin từ token
      res.json({ message: 'Welcome to your profile!', user: req.user });
    });
    
    app.listen(3000, () => console.log('Server running on port 3000'));
    

    Route /profile chỉ có thể truy cập nếu request có token JWT hợp lệ. Middleware requireAuth sẽ chạy trước và đính kèm req.user nếu token đúng.

3. Sử dụng Session (Đối với ứng dụng web truyền thống)

Trong mô hình session, sau khi đăng nhập, server tạo một session cho người dùng và gửi về một session ID (thường lưu trong cookie). Browser sẽ tự động gửi cookie này trong các request tiếp theo đến cùng domain. Server sử dụng session ID từ cookie để tìm kiếm dữ liệu session tương ứng (bao gồm thông tin người dùng) đã lưu trữ trên server.

Ví dụ Middleware kiểm tra Session (giả định dùng express-session hoặc tương tự):

// src/middleware/requireSession.ts
import { Request, Response, NextFunction } from 'express';
// Cần cài đặt và cấu hình thư viện quản lý session như 'express-session' trước

// Mở rộng kiểu Request để thêm thông tin session/user từ thư viện session
interface SessionRequest extends Request {
  session?: { // Kiểu này có thể khác tùy thuộc vào thư viện session
    userId?: string; // ID người dùng được lưu trong session
    // Các dữ liệu session khác
  };
  user?: any; // Hoặc một kiểu User cụ thể sau khi lookup user
}

export const requireSession = async (req: SessionRequest, res: Response, next: NextFunction) => {
  // Kiểm tra xem session có tồn tại và có chứa thông tin xác thực không
  if (!req.session || !req.session.userId) {
    return res.status(401).json({ message: 'Unauthorized: Session missing or invalid' });
  }

  // Tùy chọn: Lấy thông tin đầy đủ của người dùng từ database dựa vào req.session.userId
  // Đây là bước Authorization (kiểm tra quyền dựa trên user object)
  // try {
  //   const user = await findUserById(req.session.userId); // findUserById là hàm giả định
  //   if (!user) {
  //      // Session ID tồn tại nhưng user không còn -> session lỗi thời
  //      req.session.destroy(() => {}); // Xóa session
  //      return res.status(401).json({ message: 'Unauthorized: User session invalid' });
  //   }
  //   req.user = user; // Đính kèm thông tin user vào request
  // } catch (error) {
  //    console.error('Error fetching user for session:', error);
  //    return res.status(500).json({ message: 'Internal server error during session lookup' });
  // }


  // Nếu session hợp lệ, chuyển tiếp
  next();
};

// Hàm giả định để lấy user từ ID, cần triển khai thực tế
// async function findUserById(userId: string): Promise<any | null> {
//   // Logic truy vấn database
//   return { id: userId, name: 'Example User', role: 'viewer' }; // Ví dụ trả về
// }
  • Giải thích:

    • Middleware này dựa vào một thư viện quản lý session đã được cấu hình trước đó (như express-session).
    • Nó kiểm tra xem req.session có tồn tại và có chứa thông tin xác thực (ví dụ: userId) hay không.
    • Nếu không, trả về 401 Unauthorized.
    • Phần code bị comment cho thấy cách bạn có thể tùy chọn dùng userId trong session để lấy thông tin người dùng đầy đủ từ database và đính kèm vào request, phục vụ cho bước ủy quyền sau này.
  • Cách sử dụng (ví dụ với Express và express-session):

    import express from 'express';
    import session from 'express-session'; // Cần cài đặt: npm install express-session @types/express-session
    import { requireSession } from './middleware/requireSession';
    
    const app = express();
    
    // Cấu hình session middleware (cần thêm secret key và storage thực tế)
    app.use(session({
      secret: process.env.SESSION_SECRET || 'default-secret', // LƯU TRỮ BẢO MẬT!
      resave: false,
      saveUninitialized: false,
      cookie: { secure: process.env.NODE_ENV === 'production' } // Sử dụng secure cookie trong production
    }));
    
    // Route đăng nhập tạo session
    app.post('/login', (req: SessionRequest, res) => {
      // Logic kiểm tra email/mật khẩu...
      // Nếu thành công:
      req.session!.userId = 'user123'; // Lưu user ID vào session
      res.json({ message: 'Logged in successfully' });
    });
    
    // Route cần bảo vệ bằng session
    app.get('/dashboard', requireSession, (req, res) => {
      // req.session.userId có sẵn ở đây
      res.json({ message: 'Welcome to the dashboard!' });
    });
    
    app.listen(3000, () => console.log('Server running on port 3000'));
    

    requireSession sẽ kiểm tra cookie session do express-session tạo ra.

4. Ủy quyền (Authorization)

Sau khi xác thực (biết ai đang gửi request), chúng ta cần kiểm tra xem người đó có quyền thực hiện hành động này không. Đây là bước ủy quyền. Ủy quyền thường dựa trên vai trò (Role-Based Access Control - RBAC) hoặc các quyền cụ thể được gán cho người dùng.

Ví dụ Middleware kiểm tra Vai trò (Authorization):

Middleware này sẽ chạy sau middleware xác thực (ví dụ: requireAuth hoặc requireSession), bởi vì nó cần biết người dùng là ai để kiểm tra quyền.

// src/middleware/requireRole.ts
import { Request, Response, NextFunction } from 'express';
// Giả định AuthenticatedRequest interface đã được định nghĩa trong middleware xác thực
// và đính kèm thông tin user vào req

// Mở rộng kiểu Request với thông tin user có role
interface AuthorizedRequest extends Request {
  user?: {
    id: string;
    email: string;
    role: 'admin' | 'editor' | 'viewer'; // Ví dụ các vai trò
    // Có thể có mảng permissions: string[];
  };
}

// Middleware factory: tạo ra middleware kiểm tra vai trò cụ thể
export const requireRole = (requiredRole: 'admin' | 'editor') => {
  return (req: AuthorizedRequest, res: Response, next: NextFunction) => {
    // Đầu tiên, kiểm tra xem request đã được xác thực chưa
    // (middleware requireAuth/requireSession phải chạy trước cái này)
    if (!req.user || !req.user.role) {
      // Trường hợp này không nên xảy ra nếu thứ tự middleware đúng,
      // nhưng kiểm tra lại cho an toàn
      console.error("Authorization middleware called before Authentication middleware.");
      return res.status(500).json({ message: "Server misconfiguration." });
    }

    // Kiểm tra vai trò của người dùng
    // Ví dụ: chấp nhận vai trò yêu cầu HOẶC vai trò 'admin'
    if (req.user.role !== requiredRole && req.user.role !== 'admin') {
      return res.status(403).json({ message: `Forbidden: Requires "${requiredRole}" or "admin" role.` });
    }

    // Nếu người dùng có vai trò yêu cầu hoặc là admin, cho phép đi tiếp
    next();
  };
};

// Ví dụ về một middleware kiểm tra quyền cụ thể thay vì vai trò
// export const requirePermission = (requiredPermission: string) => {
//   return (req: AuthorizedRequest, res: Response, next: NextFunction) => {
//     if (!req.user || !req.user.permissions || !req.user.permissions.includes(requiredPermission)) {
//        return res.status(403).json({ message: `Forbidden: Requires "${requiredPermission}" permission.` });
//     }
//     next();
//   };
// };
  • Giải thích:

    • Đây là một middleware factory (hàm trả về một hàm middleware). Điều này cho phép chúng ta tái sử dụng logic kiểm tra vai trò cho các vai trò khác nhau (ví dụ: requireRole('admin'), requireRole('editor')).
    • Middleware này giả định rằng req.user đã được đính kèm bởi một middleware xác thực chạy trước đó.
    • Nó kiểm tra thuộc tính role trên req.user.
    • Nếu vai trò không khớp với vai trò yêu cầu VÀ không phải là 'admin' (trong ví dụ này admin có quyền cao nhất), nó trả về 403 Forbidden.
    • Nếu vai trò phù hợp, gọi next().
  • Cách sử dụng (kết hợp Xác thực và Ủy quyền): ```typescript import express from 'express'; import { requireAuth, AuthenticatedRequest } from './middleware/requireAuth'; // Import cả interface nếu cần import { requireRole } from './middleware/requireRole'; // Import middleware factory

    const app = express(); app.use(express.json());

    // Route chỉ admin mới truy cập được app.get('/admin/users', requireAuth, requireRole('admin'), (req: AuthenticatedRequest, res) => { // Logic lấy danh sách người dùng (chỉ admin) console.log(Admin "${req.user?.email}" is accessing user list.); res.json({ users: [] / ... / }); });

    // Route chỉ editor hoặc admin mới được tạo bài viết app.post('/posts', requireAuth, requireRole('editor'), (req: AuthenticatedRequest, res) => { // Logic tạo bài viết console.log(User "${req.user?.email}" with role "${req.user?.role}" is creating a post.); res.status(201).json({ message: 'Post created successfully!' }); });

    // Route mà mọi người dùng đã đăng nhập đều truy cập được app.get('/settings', requireAuth, (req: AuthenticatedRequest, res) => {

    res.json({ settings: {} /* settings của user */ });

    });

app.listen(3000, () => console.log('Server running on port 3000'));
```
Trong ví dụ này, request đến `/admin/users` phải đi qua 3 bước:
1.  `requireAuth`: Kiểm tra xem request có token JWT hợp lệ không. Nếu không, dừng lại với 401/403. Nếu có, đính kèm `req.user`.
2.  `requireRole('admin')`: Kiểm tra xem `req.user.role` có phải là 'admin' không (hoặc logic kiểm tra khác trong middleware). Nếu không, dừng lại với 403.
3.  Nếu cả hai middleware trên đều gọi `next()`, request mới đến được hàm xử lý cuối cùng `(req, res) => { ... }`.

Tầm quan trọng của TypeScript

Việc sử dụng TypeScript trong quá trình này mang lại nhiều lợi ích:

  • An toàn kiểu dữ liệu (Type Safety): Bằng cách định nghĩa interface (ví dụ: AuthenticatedRequest, AuthorizedRequest), chúng ta đảm bảo rằng đối tượng req.user có cấu trúc dự kiến. Trình biên dịch TypeScript sẽ báo lỗi nếu bạn cố gắng truy cập một thuộc tính không tồn tại trên req.user, giúp ngăn ngừa các lỗi runtime liên quan đến dữ liệu người dùng.
  • Tự hoàn thành (Autocompletion) và Hỗ trợ Refactoring: IDE của bạn có thể cung cấp gợi ý code khi bạn làm việc với req.user và các thuộc tính của nó (req.user.id, req.user.role), giúp tăng năng suất và giảm lỗi chính tả.
  • Code dễ đọc và hiểu hơn: Việc khai báo rõ ràng kiểu dữ liệu giúp những người khác đọc code của bạn dễ dàng hiểu được cấu trúc dữ liệu đang được truyền đi giữa các middleware.

Các Biện Pháp Bảo Vệ Khác (TypeScript hỗ trợ)

Ngoài xác thực và ủy quyền, còn có các lớp bảo vệ khác mà TypeScript có thể hỗ trợ:

  • Input Validation: Xác thực dữ liệu đầu vào từ client là cực kỳ quan trọng để ngăn chặn các cuộc tấn công injection (SQL Injection, XSS) và đảm bảo tính toàn vẹn dữ liệu. Sử dụng các thư viện validation với hỗ trợ TypeScript (như zod, yup, class-validator) giúp định nghĩa cấu trúc dữ liệu mong muốn và kiểm tra request body/query/params theo kiểu dữ liệu đó một cách an toàn.
  • Rate Limiting: Hạn chế số lượng request từ một IP hoặc người dùng trong một khoảng thời gian nhất định để chống lại tấn công Brute Force hoặc DDoS. Middleware cho Rate Limiting có thể được viết bằng TypeScript.
  • CORS (Cross-Origin Resource Sharing): Cấu hình CORS chính xác để chỉ cho phép các domain frontend đáng tin cậy truy cập API của bạn. Các thư viện CORS phổ biến đều có định nghĩa kiểu TypeScript.

Comments

There are no comments at the moment.