Menu
CoddyTech

تحديد معدل تسجيل الدخول

جزء من قسم المصادقة والأمان في رحلة Node.js على Coddy. الدرس 23 من 24.

يمكن لبرنامج نصي أن يجرّب آلاف كلمات المرور في الدقيقة على حساب واحد. احسب محاولات تسجيل الدخول الفاشلة لكل اسم مستخدم، وبعد خمس محاولات خلال دقيقة، ارفض التحقق من أي محاولات أخرى حتى تنتهي الدقيقة.

تشير الحالة 429 «طلبات كثيرة جدًا» إلى السبب، ويحدد الترويس 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());
    // ... أرسل البريد الإلكتروني
});

يحصي هذا الإصدار التعليمي المحاولات الفاشلة المكتملة ضمن عملية خادم واحدة. قد تتجاوز الطلبات المتزامنة الحد أثناء انتظار اكتمال التحقق من كلمات المرور؛ لذا يجب أن يحصي محدِّد المعدل في بيئة الإنتاج المحاولة قبل التحقق من كلمة المرور، وأن يحتفظ بالعدادات في مخزن مشترك بين جميع الخوادم.

challenge icon

التحدي

سهل

تفتح هذه الخطوة مشروع واجهة برمجة التطبيقات (API) للملاحظات الخاصة المُعدّ للدورة، وليس ملفات مشروعك من Security Headers (بما في ذلك server.js). يبقى عملك السابق في درسه الخاص. اقرأ الملفات المرفقة قبل تعديل server.js. تحتوي هذه النسخة على عوامل تصفية مالك الملاحظات الخاصة، وفحوصات الأدوار، وترويسات الأمان.

يمكن لبرنامج نصي تجربة آلاف كلمات المرور في الدقيقة على حساب واحد. أبطئه: بعد 5 كلمات مرور خاطئة لاسم مستخدم واحد خلال دقيقة، أوقف التحقق من كلمات المرور لذلك الاسم حتى تنقضي الدقيقة.

  1. في server.js، أبقِ سجلًا لمحاولات تسجيل الدخول الفاشلة لكل اسم مستخدم في Map، فوق POST /login. لكل اسم، خزّن عدد مرات الفشل ووقت حدوث أول محاولة.
  2. في POST /login، ابحث عن سجل اسم المستخدم قبل التحقق من كلمة المرور. يُعامل السجل الذي مرّت دقيقة أو أكثر على أول محاولة فاشلة فيه كما لو لم يكن موجودًا. عندما يكون للاسم 5 محاولات فاشلة خلال الدقيقة، أرسل استجابة بالحالة 429 و{"error": "Too many failed logins. Try again later."}. افعل ذلك حتى لو كانت كلمة المرور صحيحة. تعني الحالة 429 Too Many Requests «أبطئ».
  3. في استجابة 429 هذه، اضبط الترويسة Retry-After على العدد الصحيح من الثواني المتبقية حتى مرور دقيقة منذ أول محاولة فاشلة، مثلًا Retry-After: 42.
  4. عندما تكون كلمة المرور خاطئة، أضف 1 إلى عدد محاولات الفشل لاسم المستخدم، ثم أرسل استجابة 401 المعتادة. تبدأ أول محاولة فاشلة، أو أول محاولة بعد انتهاء صلاحية سجل قديم، عدًّا جديدًا من 1 مع الوقت الحالي.
  5. لا تحتسب أبدًا تسجيل دخول ناجحًا. ولا تحدّ محاولات الفشل لاسم مستخدم واحد من محاولات اسم مستخدم آخر.

جرّب ذلك: اضغط على Run.

  • أرسل 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');
});

جميع دروس المصادقة والأمان

تدرّب بنفسك: Playground لـ Express