Logins begrenzen
Teil des Abschnitts Authentifizierung und Sicherheit der Node.js-Journey von Coddy. Lektion 23 von 24.
Ein Skript kann innerhalb einer Minute Tausende Passwörter für ein Konto ausprobieren. Zähle die fehlgeschlagenen Anmeldungen pro Benutzername und lehne nach fünf innerhalb einer Minute alle weiteren Versuche ab, bis die Minute vorbei ist.
Der Status 429 „Zu viele Anfragen“ erklärt den Grund, und der Header Retry-After gibt an, wie viele Sekunden gewartet werden muss. Zähle nur fehlgeschlagene Versuche: Wer das richtige Passwort eingibt, darf sich niemals selbst aussperren. Eine Map speichert Werte anhand eines Schlüssels: map.get(key) liest einen Wert und map.set(key, value) schreibt einen Wert. Sie lebt im Arbeitsspeicher, was für einen Server ausreicht, und wird geleert, wenn der Server neu gestartet wird. Dieses Beispiel erlaubt eine E-Mail zum Zurücksetzen pro Minute und speichert daher nur einen Zeitpunkt. Bei deiner Anmeldung zählst du fehlgeschlagene Versuche, daher speichert der Eintrag eine Anzahl und einen Zeitpunkt.
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());
// ... die E-Mail senden
});Diese Lernversion zählt abgeschlossene fehlgeschlagene Versuche in einem einzelnen Serverprozess. Gleichzeitige Anfragen können das Limit überschreiten, solange die Passwortprüfungen noch laufen; ein Produktiv-Limiter muss einen Versuch vor der Passwortprüfung zählen und die Zähler in einem Speicher ablegen, der von allen Servern gemeinsam genutzt wird.
Aufgabe
EinfachDieser Schritt öffnet das vorbereitete Projekt der privaten Notizen-API des Kurses und nicht deine Projektdateien aus Security Headers (einschließlich server.js). Deine frühere Arbeit bleibt in ihrer eigenen Lektion. Lies die bereitgestellten Dateien, bevor du server.js bearbeitest. Diese Kopie enthält die Besitzerfilter für private Notizen, Rollenprüfungen und Sicherheitsheader.
Ein Skript kann innerhalb einer Minute Tausende von Passwörtern für ein Konto ausprobieren. Bremse es aus: Nach 5 falschen Passwörtern für einen Benutzernamen innerhalb einer Minute darf für diesen Namen bis zum Ablauf der Minute kein Passwort mehr geprüft werden.
- Führe in
server.jsoberhalb vonPOST /loginfür jeden Benutzernamen eine Aufzeichnung fehlgeschlagener Anmeldungen in einerMap. Speichere für jeden Namen, wie viele fehlgeschlagene Versuche es gab und wann der erste stattgefunden hat. - Suche in
POST /loginden Eintrag für den Benutzernamen, bevor du das Passwort prüfst. Ein Eintrag, dessen erster fehlgeschlagener Versuch mindestens eine Minute zurückliegt, gilt als nicht vorhanden. Wenn es für den Namen innerhalb der Minute 5 fehlgeschlagene Versuche gab, antworte mit Status 429 und{"error": "Too many failed logins. Try again later."}. Tu das auch dann, wenn das Passwort richtig wäre. 429 Too Many Requests bedeutet „langsamer machen“. - Setze bei dieser Antwort mit Status 429 den Header
Retry-Afterauf die ganze Zahl der Sekunden, die noch verbleiben, bis seit dem ersten fehlgeschlagenen Versuch eine Minute vergangen ist, zum BeispielRetry-After: 42. - Wenn das Passwort falsch ist, erhöhe die Zahl der fehlgeschlagenen Versuche für diesen Benutzernamen um 1 und antworte anschließend wie üblich mit Status 401. Der erste fehlgeschlagene Versuch oder der erste nach einem veralteten Eintrag startet eine neue Zählung bei 1 und mit der aktuellen Zeit.
- Zähle erfolgreiche Anmeldungen niemals mit. Fehlgeschlagene Anmeldungen für einen Benutzernamen schränken niemals andere ein.
Teste es: Drücke auf „Ausführen“.
- Sende
POST /signupmit{"username": "ada", "password": "correct horse"}. - Sende fünfmal
POST /loginmit{"username": "ada", "password": "guess"}: jedes Mal Status 401. - Sende es ein sechstes Mal: Status 429. Im Tab „Headers“ wird
Retry-Aftermit einer Anzahl von Sekunden angezeigt, höchstens 60. - Sende das richtige Passwort,
{"username": "ada", "password": "correct horse"}: weiterhin Status 429. - Sende
POST /signupund anschließendPOST /loginmit{"username": "bob", "password": "correct horse"}: Status 200, weil nur ada eingeschränkt ist. Nach einer Minute kann sich ada wieder anmelden.
Probier es selbst
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, die den Browser anweisen, bei jeder Antwort vorsichtig zu sein.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// Das einzige Administratorkonto. Sein Passwort stammt aus der Umgebung.
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 });
});
// Findet den Benutzer zum Session-Cookie oder antwortet mit 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();
}
// Antwortet mit 403, wenn der angemeldete Benutzer diese Rolle nicht hat.
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();
});
// Deine Notizen als Webseite.
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>`);
});
// Notizen: Jeder Benutzer liest und schreibt nur seine eigenen.
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();
});
// Für Administratoren: wie viele Konten es gibt.
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');
});
Alle Lektionen in Authentifizierung und Sicherheit
1Passwörter
Bytes und Hex-TextEinen Passwortschlüssel ableitenEin neues SaltPasswörter niemals speichernSchlüssel-Bytes vergleichenEin Passwort überprüfenRegeln für die Registrierung4Eigentümer und Sicherheit
Nur deine NotizenAdmin-RollenÜbe selbstständig: Express-Playground