Giới hạn tần suất đăng nhập
Thuộc phần Xác thực và bảo mật trong hành trình Node.js của Coddy. Bài học 23 trên 24.
Một tập lệnh có thể thử hàng nghìn mật khẩu mỗi phút để đăng nhập vào một tài khoản. Hãy đếm số lần đăng nhập thất bại theo từng tên người dùng, và sau năm lần trong một phút, từ chối kiểm tra thêm cho đến khi hết phút đó.
Trạng thái 429 Too Many Requests cho biết lý do, còn tiêu đề Retry-After cho biết cần chờ bao nhiêu giây. Chỉ đếm những lần thất bại: người nhập đúng mật khẩu tuyệt đối không bị khóa tài khoản. Map lưu giá trị theo khóa: map.get(key) đọc một giá trị và map.set(key, value) ghi một giá trị. Dữ liệu nằm trong bộ nhớ, đủ dùng cho một máy chủ, và sẽ bị xóa khi máy chủ khởi động lại. Ví dụ này cho phép gửi một email đặt lại mật khẩu mỗi phút, nên chỉ lưu thời điểm gửi. Hệ thống đăng nhập của bạn đếm số lần thất bại, nên bản ghi lưu số lần và thời điểm.
const lastSent = new Map();
app.post('/reset', (req, res) => {
const email = String(req.body.email ?? '').toLowerCase();
const wait = (lastSent.get(email) ?? 0) + 60_000 - Date.now();
if (wait > 0) {
res.set('Retry-After', String(Math.ceil(wait / 1000)));
return res.status(429).json({ error: 'One reset email a minute' });
}
lastSent.set(email, Date.now());
// ... gửi email
});Phiên bản học tập này đếm số lần thất bại đã hoàn tất trong một tiến trình máy chủ. Các yêu cầu đồng thời có thể vượt quá giới hạn khi việc kiểm tra mật khẩu vẫn đang chờ xử lý; bộ giới hạn dùng trong môi trường thực tế phải đếm một lần thử trước khi kiểm tra mật khẩu và lưu số lần thử ở nơi dùng chung cho tất cả máy chủ.
Thử thách
DễBước này mở dự án API ghi chú riêng tư đã chuẩn bị sẵn cho khóa học, thay vì các tệp dự án của bạn từ Security Headers (bao gồm cả server.js). Phần bạn làm trước đó vẫn nằm trong bài học riêng. Đọc các tệp được cung cấp trước khi chỉnh sửa server.js. Bản sao này có bộ lọc chủ sở hữu ghi chú riêng tư, kiểm tra vai trò và các tiêu đề bảo mật.
Một tập lệnh có thể thử hàng nghìn mật khẩu mỗi phút với một tài khoản. Hãy làm chậm việc đó: sau 5 lần nhập sai mật khẩu cho cùng một tên người dùng trong vòng một phút, hãy ngừng kiểm tra mật khẩu của tên đó cho đến khi hết một phút.
- Trong
server.js, phía trênPOST /login, dùngMapđể lưu lại các lần đăng nhập thất bại cho từng tên người dùng. Với mỗi tên, lưu số lần thất bại và thời điểm xảy ra lần thất bại đầu tiên. - Trong
POST /login, trước khi kiểm tra mật khẩu, hãy tra cứu bản ghi của tên người dùng. Nếu lần thất bại đầu tiên đã xảy ra từ một phút trước trở lên, hãy xem như không có bản ghi. Khi tên đó có 5 lần thất bại trong vòng một phút, hãy phản hồi với trạng thái 429 và{"error": "Too many failed logins. Try again later."}. Làm vậy ngay cả khi mật khẩu đúng. 429 Too Many Requests có nghĩa là “hãy chậm lại”. - Khi phản hồi 429, hãy đặt tiêu đề
Retry-Afterthành số giây nguyên còn lại cho đến khi đủ một phút kể từ lần thất bại đầu tiên, ví dụRetry-After: 42. - Khi mật khẩu sai, hãy cộng thêm 1 vào số lần thất bại của tên người dùng đó, rồi phản hồi như thường lệ với trạng thái 401. Lần thất bại đầu tiên, hoặc lần đầu tiên sau khi bản ghi cũ hết hạn, bắt đầu lượt đếm mới từ 1 với thời điểm hiện tại.
- Không bao giờ tính lần đăng nhập thành công. Các lần thất bại của một tên người dùng không bao giờ giới hạn tên khác.
Thử ngay: Nhấn Chạy.
- Gửi
POST /signupvới{"username": "ada", "password": "correct horse"}. - Gửi
POST /loginvới{"username": "ada", "password": "guess"}năm lần: mỗi lần đều có trạng thái 401. - Gửi lần thứ sáu: trạng thái 429. Thẻ Headers hiển thị
Retry-Afterkèm số giây, tối đa là 60. - Gửi mật khẩu đúng,
{"username": "ada", "password": "correct horse"}: vẫn nhận trạng thái 429. - Gửi
POST /signup, rồiPOST /login, với{"username": "bob", "password": "correct horse"}: trạng thái 200 vì chỉ ada bị giới hạn. Sau một phút, ada có thể đăng nhập lại.
Tự làm thử
import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.disable('x-powered-by');
app.use(express.json());
// Các header yêu cầu trình duyệt xử lý cẩn thận mọi phản hồi.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// Tài khoản admin duy nhất. Mật khẩu của tài khoản này lấy từ môi trường.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
return res.status(401).json({ error: 'Wrong username or password' });
}
const sid = randomBytes(24).toString('base64url');
await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
res.cookie('sid', sid, {
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ username: user.username });
});
// Tìm người dùng qua cookie phiên, nếu không có thì trả về 401.
async function requireUser(req, res, next) {
const { sid } = parseCookies(req.get('cookie'));
const session = sid && (await sessions.findOne({ _id: sid }));
const user = session && (await users.findOne({ _id: session.userId }));
if (!user) {
return res.status(401).json({ error: 'Log in first' });
}
req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
next();
}
// Trả về 403 nếu người dùng đã đăng nhập không có vai trò này.
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Not allowed' });
}
next();
};
}
app.get('/me', requireUser, (req, res) => {
res.json({ username: req.user.username });
});
app.post('/logout', async (req, res) => {
const { sid } = parseCookies(req.get('cookie'));
if (sid) {
await sessions.deleteOne({ _id: sid });
}
res.clearCookie('sid');
res.status(204).end();
});
// Hiển thị ghi chú của bạn dưới dạng một trang.
app.get('/', requireUser, async (req, res) => {
const list = await notes.find({ owner: req.user.id }).toArray();
const items = list.map((note) => `<li>${note.text}</li>`).join('');
res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});
// Ghi chú: mỗi người dùng chỉ đọc và viết ghi chú của mình.
app.get('/notes', requireUser, async (req, res) => {
res.json(await notes.find({ owner: req.user.id }).toArray());
});
app.post('/notes', requireUser, async (req, res) => {
const note = { owner: req.user.id, text: String(req.body.text ?? '') };
await notes.insertOne(note);
res.status(201).json(note);
});
app.get('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
app.delete('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!deletedCount) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).end();
});
// Dành cho admin: số lượng tài khoản hiện có.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
res.json({ users: await users.countDocuments() });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
Tất cả bài học trong Xác thực và bảo mật
1Mật khẩu
Byte và văn bản hexTạo khóa từ mật khẩuSalt mớiKhông bao giờ lưu mật khẩuSo sánh các byte của khóaKiểm tra mật khẩuQuy tắc đăng ký4Quyền sở hữu và an toàn
Chỉ ghi chú của bạnVai trò quản trị viên2Phiên làm việc và cookie
Ghi nhớ bằng cookieTra cứu phiênPhiên đăng nhậpĐịnh tuyến sau khi đăng nhậpTự luyện tập: Playground Express