Bài 31.4: Server-side authentication

Bài 31.4: Server-side authentication
Chào mừng trở lại với chuỗi bài viết về lập trình web! Sau khi đã nắm vững các kiến thức nền tảng và xây dựng giao diện người dùng (UI) tương tác, giờ là lúc chúng ta chạm đến một khía cạnh cực kỳ quan trọng của bất kỳ ứng dụng web nào: bảo mật. Và trái tim của bảo mật người dùng chính là Authentication (Xác thực).
Authentication là quá trình xác minh danh tính của người dùng. Nói cách khác, là trả lời câu hỏi: "Bạn có thực sự là người bạn nói không?". Trong phát triển web, chúng ta thường nghe về Server-side Authentication và Client-side Authentication. Dù cả hai đều đóng vai trò trong luồng đăng nhập, nhưng Server-side Authentication mới chính là lá chắn bảo vệ dữ liệu và tài nguyên của bạn khỏi những truy cập trái phép.
Tại sao Server-side lại quan trọng đến vậy? Đơn giản là vì Server là nơi đáng tin cậy nhất trong kiến trúc ứng dụng web. Dữ liệu nhạy cảm (như mật khẩu đã hash) và logic xác thực phải nằm trên server để đảm bảo chúng không bị can thiệp hoặc xem trộm từ phía client. Mọi yêu cầu truy cập tài nguyên nhạy cảm đều cần được server kiểm tra lại danh tính trước khi xử lý.
Hãy cùng đi sâu vào các cơ chế chính của Server-side Authentication!
1. Cơ Chế Username và Password Truyền Thống
Đây là phương pháp phổ biến nhất mà bạn gặp hàng ngày. Người dùng cung cấp tên đăng nhập và mật khẩu cho ứng dụng (qua form đăng nhập). Dữ liệu này được gửi đến server để xử thực quá trình xác thực.
Luồng hoạt động cơ bản:
- Client gửi thông tin: Người dùng nhập username và password vào form trên trình duyệt và nhấn nút "Đăng nhập". Dữ liệu này được gửi đến server, thường qua phương thức
POST
và kết nối HTTPS (cực kỳ quan trọng để mã hóa dữ liệu khi truyền). - Server nhận và xử lý: Server nhận username và password.
- Server tìm kiếm người dùng: Server truy vấn cơ sở dữ liệu để tìm người dùng có username tương ứng.
- Xác minh mật khẩu: Đây là bước then chốt. Server không bao giờ lưu trữ mật khẩu dưới dạng văn bản thuần (plain text). Thay vào đó, mật khẩu của người dùng sẽ được lưu dưới dạng hash. Khi nhận mật khẩu từ client, server sẽ hash mật khẩu nhận được bằng cùng thuật toán và muối (salt) đã dùng khi đăng ký, sau đó so sánh kết quả hash này với kết quả hash đã lưu trong cơ sở dữ liệu.
- Kết quả xác thực:
- Nếu hai giá trị hash khớp nhau và tìm thấy người dùng: Xác thực thành công. Server sẽ tạo một cơ chế để duy trì trạng thái "đã đăng nhập" cho người dùng này (sẽ nói chi tiết ở phần sau: Session hoặc Token). Server phản hồi về client thông báo đăng nhập thành công.
- Nếu không tìm thấy username hoặc giá trị hash không khớp: Xác thực thất bại. Server phản hồi về client thông báo lỗi (ví dụ: "Sai tên đăng nhập hoặc mật khẩu").
Tại Sao Phải Hashing Mật Khẩu?
Hashing là quá trình biến đổi dữ liệu (mật khẩu) thành một chuỗi ký tự có độ dài cố định, không thể đảo ngược.
- Không thể đảo ngược: Bạn không thể lấy lại mật khẩu gốc từ giá trị hash.
- Nhất quán: Cùng một mật khẩu sẽ luôn cho ra cùng một giá trị hash (khi sử dụng cùng thuật toán và muối).
- Thay đổi đáng kể: Chỉ một thay đổi nhỏ trong mật khẩu gốc sẽ tạo ra một giá trị hash hoàn toàn khác.
Việc lưu trữ hash thay vì mật khẩu gốc giúp bảo vệ người dùng ngay cả khi cơ sở dữ liệu bị tấn công và rò rỉ. Kẻ tấn công chỉ lấy được các chuỗi hash vô nghĩa thay vì mật khẩu thật của người dùng.
Các thuật toán hashing hiện đại được thiết kế để chậm hơn (ví dụ: bcrypt, scrypt, Argon2) nhằm chống lại các cuộc tấn công brute-force (thử mọi mật khẩu có thể).
Ví dụ minh họa (JavaScript với thư viện bcrypt
trên Node.js):
// Giả sử bạn đã cài đặt thư viện: npm install bcrypt
const bcrypt = require('bcrypt');
const saltRounds = 10; // Số vòng lặp để tăng độ khó
// Bước 1: Khi người dùng đăng ký hoặc đổi mật khẩu
async function hashPassword(password) {
try {
const hashedPassword = await bcrypt.hash(password, saltRounds);
console.log("Mật khẩu đã hash để lưu vào DB:", hashedPassword);
return hashedPassword; // Lưu giá trị này vào DB
} catch (err) {
console.error("Lỗi khi hash mật khẩu:", err);
throw err;
}
}
// Bước 2: Khi người dùng đăng nhập
async function verifyPassword(inputPassword, storedHash) {
try {
const match = await bcrypt.compare(inputPassword, storedHash);
if (match) {
console.log("Mật khẩu khớp! Đăng nhập thành công.");
} else {
console.log("Mật khẩu không khớp! Đăng nhập thất bại.");
}
return match; // Trả về true nếu khớp, false nếu không
} catch (err) {
console.error("Lỗi khi so sánh mật khẩu:", err);
throw err;
}
}
// Sử dụng ví dụ:
const userPassword = "mysecretpassword";
let storedHashInDB = "";
hashPassword(userPassword)
.then(hashed => {
storedHashInDB = hashed;
console.log("Hash đã lưu:", storedHashInDB);
// Giả lập đăng nhập với mật khẩu đúng
console.log("\nKiểm tra với mật khẩu đúng:");
verifyPassword("mysecretpassword", storedHashInDB);
// Giả lập đăng nhập với mật khẩu sai
console.log("\nKiểm tra với mật khẩu sai:");
verifyPassword("wrongpassword", storedHashInDB);
})
.catch(err => console.error(err));
Trong ví dụ này, bcrypt.hash
tạo ra một chuỗi hash từ mật khẩu và một "salt" (muối) ngẫu nhiên (được tích hợp sẵn trong kết quả hash). bcrypt.compare
nhận mật khẩu người dùng nhập vào và chuỗi hash lưu trong DB, tự động trích xuất salt từ chuỗi hash và thực hiện quá trình hashing lại mật khẩu nhập vào để so sánh.
2. Duy Trì Trạng Thái Đăng Nhập (Managing Authentication State)
Sau khi server xác thực thành công danh tính người dùng, server cần một cách để "nhớ" rằng người dùng này đã đăng nhập cho các yêu cầu tiếp theo. Nếu không, người dùng sẽ phải đăng nhập lại trên mỗi trang hoặc mỗi lần thực hiện hành động yêu cầu xác thực. Có hai phương pháp phổ biến: Sessions và Tokens.
2.1. Sessions
Sessions (phiên làm việc) là một cơ chế stateful (có trạng thái) trên server.
- Khi người dùng đăng nhập thành công, server tạo ra một đối tượng session để lưu trữ thông tin về người dùng đó (ID người dùng, vai trò, thời gian đăng nhập...).
- Server tạo một Session ID duy nhất cho session này.
- Server gửi Session ID này về client, thường là dưới dạng một Cookie được đặt trên trình duyệt của người dùng. Cookie này có cài đặt bảo mật (ví dụ:
HttpOnly
,Secure
) để hạn chế rủi ro. - Với mỗi yêu cầu tiếp theo từ trình duyệt, cookie chứa Session ID sẽ được tự động gửi kèm theo.
- Server nhận yêu cầu, đọc Session ID từ cookie, dùng Session ID đó để tra cứu đối tượng session tương ứng trên server.
- Nếu tìm thấy session hợp lệ, server biết đó là người dùng nào và xử lý yêu cầu. Nếu không tìm thấy hoặc session đã hết hạn, yêu cầu bị từ chối hoặc chuyển hướng đến trang đăng nhập.
Ưu điểm của Sessions:
- Thông tin nhạy cảm (vai trò, ID người dùng...) được lưu trên server, không lộ ra ngoài client.
- Dễ dàng thu hồi (invalidate) một session cụ thể bất cứ lúc nào (ví dụ: khi người dùng đăng xuất, khi admin khóa tài khoản).
Nhược điểm của Sessions:
- Stateful: Server phải lưu trữ trạng thái cho tất cả các session đang hoạt động. Điều này có thể trở thành điểm nghẽn khi số lượng người dùng tăng lên (thách thức về bộ nhớ và khả năng mở rộng - scalability). Cần các giải pháp phức tạp hơn như shared session storage (Redis, Memcached) trong kiến trúc microservices hoặc load balancing.
- Khó khăn hơn khi triển khai trên kiến trúc serverless hoặc microservices phân tán.
Ví dụ conceptual (Server-side, Node.js/Express với thư viện express-session
):
// Giả sử bạn đã cài đặt: npm install express-session
const express = require('express');
const session = require('express-session');
const app = express();
// Cấu hình session middleware
app.use(session({
secret: 'daylachuoiscretcuaban', // Cần một chuỗi ngẫu nhiên, bảo mật
resave: false, // Không lưu lại session nếu không có thay đổi
saveUninitialized: false, // Không tạo session cho các yêu cầu chưa có session
cookie: { secure: process.env.NODE_ENV === 'production' } // Sử dụng secure cookie trong production
}));
// Endpoint đăng nhập
app.post('/login', (req, res) => {
// ... (code xác minh username/password như trên) ...
if (authenticationSuccessful) {
// Gán thông tin người dùng vào session
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
// Server tự động gửi Session ID về client trong cookie HTTP response
res.send('Đăng nhập thành công!');
} else {
res.status(401).send('Sai tên đăng nhập hoặc mật khẩu');
}
});
// Endpoint cần xác thực (ví dụ: xem thông tin profile)
app.get('/profile', (req, res) => {
// Kiểm tra xem người dùng đã đăng nhập chưa (có session không)
if (req.session && req.session.userId) {
// Có session, có thể lấy thông tin từ req.session
res.send(`Chào mừng, ${req.session.username}! Vai trò của bạn là: ${req.session.role}`);
} else {
// Không có session hoặc session không hợp lệ
res.status(401).send('Bạn cần đăng nhập để xem trang này.');
}
});
// Endpoint đăng xuất
app.post('/logout', (req, res) => {
// Hủy session trên server
req.session.destroy((err) => {
if (err) {
console.error('Lỗi khi hủy session:', err);
res.status(500).send('Lỗi đăng xuất');
} else {
// Xóa cookie session ở client (nếu cần, thường server tự làm khi session bị hủy)
res.clearCookie('connect.sid'); // Tên cookie mặc định của express-session
res.send('Đăng xuất thành công.');
}
});
});
// ... các cấu hình server khác ...
// app.listen(3000, () => console.log('Server đang chạy trên cổng 3000'));
Trong ví dụ này, req.session
là đối tượng mà server sử dụng để lưu trữ dữ liệu cho phiên làm việc hiện tại. Thư viện express-session
tự động quản lý việc tạo, gửi Session ID (trong cookie) và tra cứu session tương ứng dựa trên Session ID nhận được từ client.
2.2. Tokens (Ví dụ: JSON Web Tokens - JWT)
Tokens là một cơ chế stateless (không trạng thái) phổ biến, đặc biệt trong các kiến trúc API, mobile backend hoặc microservices. JWT (JSON Web Token) là một định dạng token rất phổ biến.
- Khi người dùng đăng nhập thành công, server tạo ra một token (thường là JWT). Token này chứa thông tin về người dùng (nhưng không phải mật khẩu!), được mã hóa và ký bằng một secret key chỉ có server biết.
- Server gửi token này về client (thường trong response body hoặc một cookie).
- Client có trách nhiệm lưu trữ token này (ví dụ: trong
localStorage
,sessionStorage
, hoặc cookie HTTP-only). - Với mỗi yêu cầu tiếp theo đến server, client sẽ đính kèm token vào request, thường là trong header
Authorization
dưới dạngBearer [token]
. - Server nhận yêu cầu, đọc token từ header. Server sau đó xác minh chữ ký của token bằng cách sử dụng cùng secret key đã dùng để ký token ban đầu.
- Nếu chữ ký hợp lệ, server tin tưởng thông tin trong token (payload) và xử lý yêu cầu dựa trên thông tin đó. Server không cần tra cứu trạng thái của session trong bộ nhớ hay database.
- Nếu chữ ký không hợp lệ hoặc token hết hạn, yêu cầu bị từ chối.
Cấu trúc của JWT:
Một JWT thường có 3 phần, ngăn cách bởi dấu chấm (.
):
- Header: Chứa loại token (JWT) và thuật toán ký (ví dụ: HS256).
- Payload: Chứa các claims (thông tin về người dùng, thời gian hết hạn token...). Lưu ý: Payload không được mã hóa, chỉ được base64-encode, nên không đặt thông tin quá nhạy cảm vào đây.
- Signature: Được tạo bằng cách kết hợp Header, Payload, và secret key của server, sau đó áp dụng thuật toán ký. Phần này đảm bảo tính toàn vẹn (dữ liệu không bị thay đổi trên đường truyền) và xác thực (được tạo ra bởi server đáng tin cậy).
Ưu điểm của Tokens (JWT):
- Stateless: Server không cần lưu trữ trạng thái của từng người dùng, giúp dễ dàng mở rộng (scale horizontally). Bất kỳ server nào cũng có thể xác minh token chỉ bằng secret key.
- Dễ dàng triển khai trong kiến trúc microservices hoặc serverless.
- Token có thể chứa thông tin cơ bản về người dùng, giúp server xử lý nhanh hơn mà không cần tra cứu DB cho mọi yêu cầu (nhưng cần cẩn trọng về thông tin đưa vào).
Nhược điểm của Tokens (JWT):
- Khó thu hồi tức thời: Một khi token đã được phát hành, nó thường có hiệu lực cho đến khi hết hạn (trừ khi bạn xây dựng một cơ chế blacklist phức tạp). Session thì có thể hủy ngay lập tức trên server.
- Token được lưu trên client, cần cẩn trọng với các cuộc tấn công XSS nếu token lưu ở
localStorage
(nên cân nhắc sử dụng cookie HTTP-only cho token nếu có thể). - Token có thể trở nên lớn nếu chứa nhiều thông tin trong payload.
Ví dụ minh họa (JavaScript với thư viện jsonwebtoken
trên Node.js):
// Giả sử bạn đã cài đặt: npm install jsonwebtoken dotenv
const jwt = require('jsonwebtoken');
const express = require('express');
const app = express();
app.use(express.json()); // Để đọc body JSON
// Cần một secret key mạnh mẽ và bảo mật. Lưu trong biến môi trường!
const JWT_SECRET = process.env.JWT_SECRET || 'secretsupermanbaomatkhongainobiet'; // Thay bằng secret thật và dùng biến môi trường!
// Endpoint đăng nhập (sau khi đã xác minh username/password)
app.post('/login', (req, res) => {
const { username, password } = req.body;
// ... (Code xác minh username/password với hashing như ví dụ trên) ...
if (authenticationSuccessful) {
// Tạo payload cho token (không chứa mật khẩu!)
const payload = {
userId: user.id,
username: user.username,
role: user.role
// Có thể thêm các thông tin khác cần thiết
};
// Tạo token với payload và secret key, đặt thời gian hết hạn
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); // Token hết hạn sau 1 giờ
// Gửi token về client
res.json({ success: true, token: token });
} else {
res.status(401).json({ success: false, message: 'Sai tên đăng nhập hoặc mật khẩu' });
}
});
// Middleware để xác minh token cho các request cần bảo vệ
function authenticateToken(req, res, next) {
// Lấy token từ header Authorization (ví dụ: "Bearer YOUR_TOKEN")
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Tách lấy phần token sau "Bearer "
if (token == null) {
// Không có token -> Từ chối truy cập
return res.sendStatus(401); // Unauthorized
}
// Xác minh token
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
// Token không hợp lệ (sai chữ ký, hết hạn...) -> Từ chối truy cập
return res.sendStatus(403); // Forbidden (đã biết người dùng là ai (có token), nhưng token không hợp lệ)
}
// Token hợp lệ, lưu thông tin người dùng (payload) vào request để các route handler sử dụng
req.user = user; // user ở đây chính là payload mà ta đã ký ban đầu
next(); // Cho phép request đi tiếp đến route handler
});
}
// Endpoint cần xác thực, sử dụng middleware authenticateToken
app.get('/protected', authenticateToken, (req, res) => {
// Nếu đến được đây, tức là token đã hợp lệ
res.json({ message: 'Đây là dữ liệu chỉ dành cho người dùng đã đăng nhập', user: req.user });
});
// ... các cấu hình server khác ...
// app.listen(3000, () => console.log('Server đang chạy trên cổng 3000'));
Trong ví dụ này:
jwt.sign
tạo ra token từ payload và secret key.authenticateToken
là một middleware (hàm xử lý request trước khi đến route handler chính). Nó kiểm tra headerAuthorization
, lấy token ra và dùngjwt.verify
để kiểm tra tính hợp lệ của token.- Nếu token hợp lệ,
jwt.verify
trả về payload (thông tin người dùng) mà ta gán vàoreq.user
để route handler/protected
có thể sử dụng.
3. Bảo Vệ Route / Endpoint Bằng Middleware
Như đã thấy trong các ví dụ trên, cả Session và Token đều sử dụng chung một kỹ thuật trên server để bảo vệ các tài nguyên (API endpoints, trang web): Middleware.
Middleware là các hàm được chạy trước khi request đến được hàm xử lý chính của route. Trong ngữ cảnh authentication, middleware có nhiệm vụ:
- Nhận request từ client.
- Kiểm tra xem request có chứa thông tin xác thực hợp lệ không (Session ID trong cookie hay Token trong header).
- Nếu có: Xác minh tính hợp lệ của thông tin đó (kiểm tra session trên server, xác minh chữ ký token). Nếu hợp lệ, đính kèm thông tin người dùng vào request (ví dụ:
req.user
,req.session.user
) và gọinext()
để request tiếp tục đến route handler. - Nếu không có hoặc không hợp lệ: Trả về phản hồi lỗi (ví dụ: HTTP status code 401 Unauthorized hoặc 403 Forbidden) và không gọi
next()
, ngăn request truy cập vào tài nguyên được bảo vệ.
Việc sử dụng middleware giúp tập trung logic kiểm tra xác thực vào một nơi duy nhất, làm cho code của các route handler sạch sẽ và dễ quản lý hơn.
4. Một Số Vấn Đề Bảo Mật Cần Lưu Ý
Ngoài hashing mật khẩu và chọn cơ chế duy trì trạng thái phù hợp, Server-side Authentication còn đòi hỏi nhiều lớp bảo vệ khác:
- HTTPS/SSL/TLS: Luôn luôn sử dụng HTTPS để mã hóa toàn bộ quá trình truyền dữ liệu giữa client và server, bao gồm cả thông tin đăng nhập và token/session ID.
- Bảo vệ Secret Key (cho JWT): Secret key dùng để ký JWT là cực kỳ quan trọng. Nó phải được lưu trữ ở nơi an toàn (ví dụ: biến môi trường của server, dịch vụ quản lý secret) và không bao giờ đưa vào code nguồn công khai.
- Tấn công Brute Force và Rate Limiting: Kẻ tấn công có thể thử đăng nhập hàng nghìn lần với các mật khẩu khác nhau. Server cần có cơ chế rate limiting (giới hạn số lần thử trong một khoảng thời gian) và account lockout (khóa tài khoản sau nhiều lần thử sai) để ngăn chặn kiểu tấn công này.
- Ngăn chặn SQL Injection: Khi tìm kiếm người dùng trong database dựa trên username, hãy luôn luôn sử dụng prepared statements hoặc ORM để tránh các lỗ hổng SQL Injection, nơi kẻ tấn công có thể chèn mã độc vào câu truy vấn database qua trường username.
- Quản lý Token/Session hết hạn và Refresh Token: Cần có chiến lược để xử lý khi token hết hạn. Refresh Token là một cơ chế giúp người dùng lấy token mới mà không cần đăng nhập lại (cần được triển khai cẩn thận).
- CSRF Protection (đối với Session Cookies): Nếu sử dụng session với cookie, cần triển khai biện pháp chống tấn công CSRF (Cross-Site Request Forgery).
Comments