Menu
CoddyTech

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.

challenge icon

Tantangan

Mudah

Langkah 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.

  1. Di server.js, di atas POST /login, simpan catatan login yang gagal untuk setiap nama pengguna dalam Map. Untuk setiap nama, simpan jumlah kegagalan dan waktu kegagalan pertama.
  2. 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".
  3. Pada respons 429 tersebut, atur header Retry-After ke jumlah detik bulat yang tersisa sampai satu menit berlalu sejak kegagalan pertama, misalnya Retry-After: 42.
  4. 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.
  5. Jangan pernah menghitung login yang berhasil. Kegagalan untuk satu nama pengguna tidak pernah membatasi nama pengguna lain.

Coba: Tekan Jalankan.

  • Kirim POST /signup dengan {"username": "ada", "password": "correct horse"}.
  • Kirim POST /login dengan {"username": "ada", "password": "guess"} sebanyak lima kali: status 401 setiap kali.
  • Kirim sekali lagi: status 429. Tab Headers menampilkan Retry-After dengan jumlah detik, paling banyak 60.
  • Kirim kata sandi yang benar, {"username": "ada", "password": "correct horse"}: tetap 429.
  • Kirim POST /signup, lalu POST /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

Latihan sendiri: Playground Express