Membatasi Laju Login
Termasuk bagian Autentikasi dan Keamanan dalam perjalanan Node.js di Coddy. Pelajaran 23 dari 24.
Sebuah skrip dapat mencoba ribuan kata sandi per menit pada satu akun. Hitung jumlah login yang gagal untuk setiap nama pengguna, lalu setelah lima kali dalam satu menit, tolak pemeriksaan berikutnya sampai menit itu berakhir.
Status 429 Too Many Requests menjelaskan alasannya, dan header Retry-After menyatakan berapa detik kamu harus menunggu. Hitung hanya kegagalan: seseorang yang mengetik kata sandi yang benar tidak boleh terkunci dari akunnya sendiri. Map menyimpan nilai berdasarkan kunci: map.get(key) membaca nilai, dan map.set(key, value) menulis nilai. Data ini berada di memori, yang cukup untuk satu server, dan akan kosong saat server dimulai ulang. Contoh ini mengizinkan satu email pengaturan ulang per menit, jadi hanya menyimpan waktu. Loginmu menghitung kegagalan, jadi catatannya menyimpan jumlah dan waktu.
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());
// ... kirim emailnya
});Versi pembelajaran ini menghitung kegagalan yang telah selesai dalam satu proses server. Permintaan yang berjalan bersamaan dapat melampaui batasnya saat pemeriksaan kata sandi masih berlangsung. Pembatas laju untuk penggunaan produksi harus menghitung upaya sebelum memeriksa kata sandi dan menyimpan jumlahnya di penyimpanan yang digunakan bersama oleh semua server.
Tantangan
MudahLangkah ini membuka proyek API catatan pribadi yang telah disiapkan untuk kursus, bukan file proyekmu dari Security Headers (termasuk server.js). Pekerjaanmu sebelumnya tetap berada di pelajarannya sendiri. Baca file yang disediakan sebelum mengedit server.js. Salinan ini berisi filter pemilik catatan pribadi, pemeriksaan peran, dan header keamanan.
Sebuah skrip dapat mencoba ribuan kata sandi per menit terhadap satu akun. Perlambat: setelah 5 kata sandi salah untuk satu nama pengguna dalam satu menit, berhenti memeriksa kata sandi untuk nama tersebut sampai menit itu berlalu.
- Di
server.js, di atasPOST /login, simpan catatan login yang gagal untuk setiap nama pengguna dalamMap. Untuk setiap nama, simpan jumlah kegagalan dan waktu kegagalan pertama. - Di
POST /login, sebelum memeriksa kata sandi, cari catatan nama pengguna tersebut. Catatan dengan kegagalan pertama yang terjadi semenit atau lebih yang lalu dianggap tidak ada. Ketika nama tersebut mengalami 5 kegagalan dalam satu menit, kirim respons dengan status 429 dan{"error": "Too many failed logins. Try again later."}. Lakukan ini meskipun kata sandinya benar. 429 Too Many Requests berarti "perlambat". - Pada respons 429 tersebut, atur header
Retry-Afterke jumlah detik bulat yang tersisa sampai satu menit berlalu sejak kegagalan pertama, misalnyaRetry-After: 42. - Ketika kata sandi salah, tambahkan 1 ke jumlah kegagalan nama pengguna tersebut, lalu kirim respons 401 seperti biasa. Kegagalan pertama, atau kegagalan pertama setelah catatan lama, memulai hitungan baru dari 1 dengan waktu saat ini.
- Jangan pernah menghitung login yang berhasil. Kegagalan untuk satu nama pengguna tidak pernah membatasi nama pengguna lain.
Coba: Tekan Jalankan.
- Kirim
POST /signupdengan{"username": "ada", "password": "correct horse"}. - Kirim
POST /logindengan{"username": "ada", "password": "guess"}sebanyak lima kali: status 401 setiap kali. - Kirim sekali lagi: status 429. Tab Headers menampilkan
Retry-Afterdengan jumlah detik, paling banyak 60. - Kirim kata sandi yang benar,
{"username": "ada", "password": "correct horse"}: tetap 429. - Kirim
POST /signup, laluPOST /login, dengan{"username": "bob", "password": "correct horse"}: status 200, karena hanya ada membatasi. Setelah satu menit, ada dapat login lagi.
Coba sendiri
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());
// Header yang meminta browser berhati-hati saat menangani setiap respons.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// Satu-satunya akun admin. Kata sandinya berasal dari lingkungan.
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 });
});
// Mencari pengguna dari cookie sesi, atau mengembalikan 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();
}
// Mengembalikan 403 kecuali pengguna yang sudah masuk memiliki peran ini.
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();
});
// Catatanmu ditampilkan sebagai halaman.
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>`);
});
// Catatan: setiap pengguna hanya membaca dan menulis catatannya sendiri.
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();
});
// Untuk admin: jumlah akun yang ada.
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');
});
Semua pelajaran di Autentikasi dan Keamanan
1Kata Sandi
Byte dan Teks HeksadesimalMembuat Kunci Kata SandiSalt BaruJangan Simpan Kata SandiMembandingkan Byte KunciMemeriksa Kata SandiAturan Pendaftaran4Pemilik dan Keamanan
Hanya CatatanmuPeran AdminLatihan sendiri: Playground Express