Menu
CoddyTech

أدوار المسؤول

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

يتحقق GET /admin فقط من أن أحدًا قد سجّل الدخول، لذا يراه كل عضو. يوضّح الدور المخزّن لدى المستخدم، مثل 'member' أو 'admin'، ما يُسمح له بفعله. ينشئ مصنع البرمجيات الوسيطة، وهو دالة تُعيد برمجية وسيطة، فحصًا لدور واحد لكل مسار:

function requirePlan(plan) {
    return (req, res, next) => {
        if (req.user.plan !== plan) {
            return res.status(402).json({ error: `Needs the ${plan} plan` });
        }
        next();
    };
}

app.get('/exports', requireUser, requirePlan('pro'), sendExports);

يحدّد الخادم الدور دائمًا. لا ينسخ التسجيل إلا الحقول التي يتوقعها من req.body، لذا فإن الزائر الذي يرسل "role": "admin" لن يحصل على أي صلاحيات بفضل ذلك.

challenge icon

التحدي

سهل

يعرض GET /admin عدد الحسابات الموجودة، ويمكن لكل عضو مسجّل الدخول رؤيته حاليًا. ينبغي أن يراه المسؤولون فقط. يحصل كل مستخدم على دور يحدده الخادم، ويتحقق المسار منه.

  1. في server.js، ضمن POST /signup، احفظ كل مستخدم جديد مع "role": "member". لا تأخذ الدور أبدًا من نص الطلب: فالزائر الذي يرسل "role": "admin" يظل عضوًا.
  2. اكتب دالة requireRole(role) تُرجع وسيطًا برمجيًا. يتحقق هذا الوسيط من req.user.role:
    • إذا لم تكن قيمته role، فأرسل استجابة بحالة 403 مع {"error": "Not allowed"}. تعني الحالة 403 Forbidden أن الخادم يعرف هويتك، لكن ليس مسموحًا لك بفعل ذلك. أما 401 فتعني أنه لا يعرف هويتك.
    • إذا كانت قيمته role، فاستدعِ next().
  3. أضف requireRole('admin') إلى GET /admin، بعد requireUser. فهو يقرأ req.user، الذي لا يعيّنه إلا requireUser.

ينشئ الخادم بالفعل حساب مسؤول واحدًا عند بدء تشغيله. اسم المستخدم هو admin، وكلمة مروره هي admin-pass-for-tests، وهي مأخوذة من متغير البيئة ADMIN_PASSWORD.

جرّب ذلك: اضغط على «العب». أرسل GET /admin: الحالة 401 و{"error": "Log in first"}. والآن تحاول مستخدمة تُدعى mallory جعل نفسها مسؤولة. أرسل POST /signup مع {"username": "mallory", "password": "correct horse", "role": "admin"}، ثم POST /login مع {"username": "mallory", "password": "correct horse"}. أرسل GET /admin: الحالة 403 و{"error": "Not allowed"}، لأن الحقل الإضافي لم يجعلها مسؤولة. أرسل POST /login مع {"username": "admin", "password": "admin-pass-for-tests"}، ثم GET /admin: {"users": 2}.

جرّب بنفسك

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.use(express.json());

// حساب المسؤول الوحيد. تأتي كلمة مروره من متغيرات البيئة.
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, 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();
}

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, async (req, res) => {
    res.json({ users: await users.countDocuments() });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});

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

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