Bài 31.3: Protecting API routes trong TypeScript

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 và í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ệ:
- 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.
- 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.
- 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.
- 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:
- 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.
- Ủ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ượngreq.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ọinext()
. - Nếu khớp, gọi
next()
để cho phép request đi tiếp.
- Middleware
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ùng403
thay vì401
ở đây là request có 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ượngreq
(qua interfaceAuthenticatedRequest
) để 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ý.
- Middleware này sử dụng thư viện
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ệ. MiddlewarerequireAuth
sẽ chạy trước và đính kèmreq.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.
- Middleware này dựa vào một thư viện quản lý session đã được cấu hình trước đó (như
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 doexpress-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ênreq.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()
.
- Đâ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ụ:
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ượngreq.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ênreq.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