הגבלת קצב ההתחברויות
חלק מהיחידה אימות ואבטחה במסלול ה-Node.js של Coddy. שיעור 23 מתוך 24.
סקריפט יכול לנסות אלפי סיסמאות בדקה מול חשבון אחד. ספרו את הכניסות הכושלות לפי שם משתמש, ואחרי חמש בדקה סרבו לבדוק סיסמאות נוספות עד שהדקה תסתיים.
הסטטוס 429 Too Many Requests מסביר למה, והכותרת Retry-After מציינת כמה שניות להמתין. ספרו רק ניסיונות כושלים: מי שמקליד את הסיסמה הנכונה לעולם לא אמור לנעול את עצמו מחוץ לחשבון. Map שומר ערכים לפי מפתח: map.get(key) קורא ערך, ו-map.set(key, value) כותב ערך. הוא נשמר בזיכרון, וזה מספיק לשרת אחד, והוא מתרוקן כשהשרת מופעל מחדש. הדוגמה הזאת מאפשרת לשלוח הודעת איפוס אחת בדקה, ולכן היא שומרת רק זמן. מנגנון הכניסה שלכם סופר ניסיונות כושלים, ולכן הרשומה שלו שומרת ספירה וזמן.
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());
// ... שליחת הודעת האימייל
});גרסת הלימוד הזאת סופרת ניסיונות כושלים שהושלמו בתהליך שרת אחד. בקשות מקבילות עלולות לחרוג מהמגבלה בזמן שבדיקות הסיסמה עדיין מתבצעות; מנגנון הגבלת קצב לשימוש בסביבת ייצור חייב לספור ניסיון לפני בדיקת הסיסמה ולשמור את הספירות באחסון המשותף לכל השרתים.
אתגר
קלבשלב הזה נפתח פרויקט API מוכן להערות פרטיות של הקורס, ולא את קובצי הפרויקט שלך מתוך Security Headers (כולל server.js). העבודה הקודמת שלך נשארת בשיעור משלה. קרא את הקבצים שסופקו לפני עריכת server.js. העותק הזה כולל מסנני בעלות על הערות פרטיות, בדיקות תפקידים וכותרות אבטחה.
סקריפט יכול לנסות אלפי סיסמאות בדקה כנגד חשבון אחד. האט אותו: אחרי 5 סיסמאות שגויות עבור שם משתמש אחד בתוך דקה, הפסק לבדוק סיסמאות עבור השם הזה עד שהדקה תסתיים.
- ב-
server.js, מעלPOST /login, שמור רישום של ניסיונות התחברות שנכשלו עבור כל שם משתמש ב-Map. עבור כל שם, שמור כמה ניסיונות כושלים היו ומתי התרחש הראשון. - ב-
POST /login, לפני בדיקת הסיסמה, חפש את הרישום של שם המשתמש. רישום שהניסיון הכושל הראשון שבו התרחש לפני דקה או יותר נחשב כאילו אין רישום. כאשר יש לשם 5 ניסיונות כושלים בתוך הדקה, החזר תגובה עם סטטוס 429 ו-{"error": "Too many failed logins. Try again later."}. עשה זאת גם כשהסיסמה הייתה נכונה. 429 Too Many Requests פירושו "להאט". - בתגובה עם סטטוס 429, הגדר את הכותרת
Retry-Afterלמספר שלם של השניות שנותרו עד שתעבור דקה מאז הניסיון הכושל הראשון, למשלRetry-After: 42. - כשהסיסמה שגויה, הוסף 1 למספר הניסיונות הכושלים של שם המשתמש הזה, ואז החזר את תגובת 401 הרגילה. הניסיון הכושל הראשון, או הראשון אחרי רישום ישן, מתחיל ספירה חדשה מ-1 עם השעה הנוכחית.
- לעולם אל תספור התחברות מוצלחת. ניסיונות כושלים עבור שם משתמש אחד לעולם אינם מגבילים שם אחר.
נסה זאת: לחץ על הפעלה.
- שלח
POST /signupעם{"username": "ada", "password": "correct horse"}. - שלח
POST /loginעם{"username": "ada", "password": "guess"}חמש פעמים: בכל פעם הסטטוס הוא 401. - שלח אותו בפעם השישית: הסטטוס הוא 429. הכרטיסייה Headers מציגה את
Retry-Afterעם מספר שניות, לכל היותר 60. - שלח את הסיסמה הנכונה,
{"username": "ada", "password": "correct horse"}: עדיין מתקבל 429. - שלח
POST /signup, ואזPOST /login, עם{"username": "bob", "password": "correct horse"}: הסטטוס הוא 200, כי רק ada מוגבלת. אחרי דקה, ada תוכל להתחבר שוב.
נסו בעצמכם
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());
// כותרות שמורות לדפדפן לנהוג בזהירות בכל תגובה.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// חשבון המנהל היחיד. הסיסמה שלו מגיעה מהסביבה.
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 });
});
// מאתר את המשתמש המשויך לעוגיית ההפעלה, או מחזיר 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();
}
// מחזיר 403 אלא אם למשתמש המחובר יש את התפקיד הזה.
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();
});
// ההערות שלך כמוצגות בדף.
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>`);
});
// הערות: כל משתמש קורא וכותב רק את ההערות שלו.
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();
});
// למנהלים: כמה חשבונות קיימים.
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');
});
כל השיעורים ביחידה אימות ואבטחה
תרגלו בעצמכם: Express אונליין