التفويض والأدوار و OAuth
الأدوار، والملكية، وتسجيل الدخول بحساب خارجي ⏱ نحو 45 دقيقة.
- تميّز المصادقة من التفويض، و401 من 403.
- تكتب وسيط أدوار قابلًا لإعادة الاستخدام.
- تتحقق من ملكية المورد وتتجنب
IDOR. - تشرح خطوات
OAuth 2.0ودورclient_secretوstate.
١بعد معرفة «من أنت»: ماذا يحق لك؟
المصادقة أثبتت هوية المستخدم. أما التفويض فيقرر: هل يحق له هذا الفعل على هذا المورد؟ وأشهر طريقتين:
| الطريقة | الفكرة | مثال |
|---|---|---|
| الأدوار RBAC | لكل مستخدم دور، ولكل دور صلاحيات | المعلم يرصد الدرجات، والطالب لا |
| الملكية Ownership | المستخدم يعدّل ما يملكه فقط | الطالب يعدّل واجبه هو لا واجب زميله |
| الصلاحية | admin | teacher | student |
|---|---|---|---|
| عرض المقررات | ✓ | ✓ | ✓ |
| رصد الدرجات | ✓ | ✓ | ✗ |
| حذف المستخدمين | ✓ | ✗ | ✗ |
٢وسيط الأدوار
نكتب دالة تصنع وسيطًا: allow('admin', 'teacher') تعيد وسيطًا يسمح لهذه الأدوار فقط. الدور يأتي من الرمز الموقّع، فلا يستطيع المستخدم تغييره:
const express = require('express');const jwt = require('jsonwebtoken');const app = express();app.use(express.json());const SECRET = process.env.JWT_SECRET; // مستخدمون تجريبيون (كلمة المرور محذوفة للتبسيط؛ راجع الدرس السابق)const users = [ { id: 1, name: 'المشرفة نوف', role: 'admin' }, { id: 2, name: 'المعلم سعد', role: 'teacher' }, { id: 3, name: 'الطالب يزيد', role: 'student' },];app.post('/api/login', (req, res) => { const u = users.find(x => x.id === req.body.id); if (!u) return res.status(401).json({ error: 'غير معروف' }); res.json({ token: jwt.sign({ sub: u.id, role: u.role }, SECRET, { expiresIn: '1h' }) });}); function auth(req, res, next) { try { req.user = jwt.verify((req.headers.authorization || '').slice(7), SECRET); next(); } catch { res.status(401).json({ error: 'سجّل الدخول' }); }} function allow(...roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ error: 'لا تملك صلاحية هذا الإجراء' }); } next(); };} app.get('/api/courses', auth, (req, res) => res.json(['CS242', 'CS241']));app.post('/api/grades', auth, allow('admin', 'teacher'), (req, res) => res.status(201).json({ saved: req.body }));app.delete('/api/users/:id', auth, allow('admin'), (req, res) => res.sendStatus(204)); app.listen(3000);
الطالب (رمز الطلب 0) رأى المقررات لكنه رُفض عند رصد الدرجات بـ 403. المعلم (رمز الطلب 1) رصد الدرجة، ثم رُفض عند حذف مستخدم. والطلب الأخير بلا رمز: 401.
٣ملكية المورد
الأدوار لا تكفي وحدها: كل الطلاب لهم الدور نفسه، لكن الطالب يجب ألا يعدّل واجب زميله. نقارن صاحب المورد بصاحب الرمز، مع استثناء المشرف:
const express = require('express');const jwt = require('jsonwebtoken');const app = express();app.use(express.json());const SECRET = process.env.JWT_SECRET; // مستخدمون تجريبيون (كلمة المرور محذوفة للتبسيط؛ راجع الدرس السابق)const users = [ { id: 1, name: 'المشرفة نوف', role: 'admin' }, { id: 2, name: 'المعلم سعد', role: 'teacher' }, { id: 3, name: 'الطالب يزيد', role: 'student' },];app.post('/api/login', (req, res) => { const u = users.find(x => x.id === req.body.id); if (!u) return res.status(401).json({ error: 'غير معروف' }); res.json({ token: jwt.sign({ sub: u.id, role: u.role }, SECRET, { expiresIn: '1h' }) });}); function auth(req, res, next) { try { req.user = jwt.verify((req.headers.authorization || '').slice(7), SECRET); next(); } catch { res.status(401).json({ error: 'سجّل الدخول' }); }} const homeworks = [ { id: 1, ownerId: 3, text: 'واجب يزيد' }, { id: 2, ownerId: 4, text: 'واجب طالب آخر' },]; app.put('/api/homeworks/:id', auth, (req, res) => { const hw = homeworks.find(h => h.id === Number(req.params.id)); if (!hw) return res.status(404).json({ error: 'غير موجود' }); if (hw.ownerId !== req.user.sub && req.user.role !== 'admin') { return res.status(403).json({ error: 'هذا ليس واجبك' }); } hw.text = req.body.text; res.json(hw);}); app.listen(3000);
٤OAuth 2.0: «سجّل الدخول بحساب Google»
بدل أن ينشئ المستخدم كلمة مرور جديدة لموقعك، يثبت هويته عند مزوّد يثق به (Google أو Microsoft أو GitHub)، ثم يعطيك المزوّد إذنًا محدودًا. موقعك لا يرى كلمة المرور أبدًا. خطوات «رمز التفويض» Authorization Code:
- تطبيقك يحوّل المستخدم إلى صفحة المزوّد ومعه
client_idوredirect_uriوstateعشوائي. - المستخدم يسجّل الدخول عند المزوّد ويوافق على مشاركة بريده واسمه.
- المزوّد يعيده إلى
redirect_uriفي تطبيقك ومعه رمز مؤقتcodeوstateنفسه. - خادمك (لا المتصفح) يستبدل الرمز المؤقت برمز وصول، مستخدمًا
client_secretالسري. - خادمك يطلب بيانات المستخدم من المزوّد، ثم ينشئ له جلسة أو JWT خاصًّا بموقعك.
هذا خادم يحاكي التدفق كاملًا، والمزوّد «الوهمي» في المسارات /provider/… ضمن الخادم نفسه للتجربة فقط. افتحه في 🌐 المتصفح المصغّر واضغط الزر، وتابع التحويلات في 📜 السجل:
const express = require('express');const crypto = require('crypto');const app = express();app.use(express.urlencoded({ extended: false })); const CLIENT_ID = 'tatbeeqha-app', CLIENT_SECRET = 'secret-on-server-only';const pendingStates = new Set(), codes = new Map(), sessions = new Map(); // ======== تطبيقنا ========app.get('/', (req, res) => { res.send('<h1>منصة المقررات</h1><a href="/auth/login">🔐 سجّل الدخول بحساب الكلية</a>');}); app.get('/auth/login', (req, res) => { // (1) const state = crypto.randomUUID(); pendingStates.add(state); res.redirect(`/provider/authorize?client_id=${CLIENT_ID}&redirect_uri=/auth/callback&state=${state}`);}); app.get('/auth/callback', (req, res) => { // (3) ثم (4) و (5) const { code, state } = req.query; if (!pendingStates.delete(state)) return res.status(400).send('state غير صحيح'); const user = providerExchange(code, CLIENT_SECRET); // في الحقيقة: طلب POST إلى المزوّد if (!user) return res.status(401).send('رمز مؤقت غير صالح'); const sid = crypto.randomUUID(); sessions.set(sid, user); res.cookie('sid', sid, { httpOnly: true }).redirect('/me');}); app.get('/me', (req, res) => { const sid = (req.headers.cookie || '').replace('sid=', ''); const user = sessions.get(sid); if (!user) return res.redirect('/'); res.send(`<h1>أهلًا ${user.name}</h1><p>بريدك: ${user.email}</p>`);}); // ======== المزوّد الوهمي (يمثّل Google مثلًا) ========app.get('/provider/authorize', (req, res) => { // (2) res.send(`<h2>مزوّد الهوية</h2><p>التطبيق ${req.query.client_id} يطلب اسمك وبريدك.</p> <form method="post" action="/provider/approve"> <input type="hidden" name="state" value="${req.query.state}"> <input type="hidden" name="redirect" value="${req.query.redirect_uri}"> <button>موافق</button></form>`);});app.post('/provider/approve', (req, res) => { const code = crypto.randomUUID(); codes.set(code, { name: 'ريم', email: 'reem@tvtc.edu.sa' }); res.redirect(`${req.body.redirect}?code=${code}&state=${req.body.state}`);});function providerExchange(code, secret) { if (secret !== CLIENT_SECRET || !codes.has(code)) return null; const user = codes.get(code); codes.delete(code); // الرمز المؤقت يُستخدم مرة واحدة return user;} app.listen(3000);
التطبيق لم يرَ كلمة مرور ريم إطلاقًا. والقيمة state تحمي من هجوم يُدخل فيه مهاجم رمزه المؤقت في جلستك (CSRF).
توقّع وتحقّق
مستخدم مسجّل دخوله طلب حذف مستخدم وهو ليس مشرفًا. الرد:
من أين نأخذ دور المستخدم بأمان؟
في OAuth، من يستبدل الرمز المؤقت code برمز وصول؟
ما فائدة state في OAuth؟
الخلاصة
RBAC:allow(...roles)بعدauth.- الملكية: قارن صاحب المورد بـ
req.user.sub. - الهوية والدور من الرمز الموقّع فقط، والحماية في الخادم لا في الواجهة.
OAuth: تحويل للمزوّد ←code← استبداله في الخادم بالسر ← جلسة/JWTخاص بك.
تحدٍّ برمجي: لوحة الإعلانات
متقدمإعلانات الكلية: الجميع يقرأ، والمعلم والمشرف ينشران، والناشر وحده (أو المشرف) يحذف إعلانه. المصادقة جاهزة.
- اكتب
allow(...roles)يرد بـ 403 لمن ليس دوره في القائمة. GET /api/adsلأي مستخدم مسجّل (وبلا رمز: 401).POST /api/adsللمعلم والمشرف فقط؛ يحفظauthorIdمن الرمز ويرد بـ 201.DELETE /api/ads/:id: صاحب الإعلان أو المشرف يحذف(204)، وغيرهما 403.
👀 اعرض الاستجابات المطلوبة
POST /api/login{"id": 1}POST /api/login{"id": 2}POST /api/login{"id": 3}GET /api/adsPOST /api/ads{"text": "اختبار قصير الأحد"} 🔑POST /api/ads{"text": "اختبار قصير الأحد"} 🔑POST /api/ads{"text": "إعلان الإدارة"} 🔑GET /api/ads🔑DELETE /api/ads/1🔑DELETE /api/ads/2🔑DELETE /api/ads/1🔑GET /api/ads🔑
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- بلا رمز ← 401
- الطالب لا ينشر ← 403
- المعلم ينشر ← 201
- … ويُحفظ authorId من الرمز
(2) - الطالب يقرأ الإعلانين
- الطالب لا يحذف إعلان المعلم ← 403
- المعلم لا يحذف إعلان المشرف ← 403
- المشرف يحذف إعلان المعلم ← 204
- بقي إعلان واحد
💡 تلميح 1
allow دالة تعيد دالة وسيطة؛ ضعها بعد auth في POST.💡 تلميح 2
authorId === req.user.sub أو الدور admin.حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const jwt = require('jsonwebtoken');const app = express();app.use(express.json());const SECRET = process.env.JWT_SECRET; // مستخدمون تجريبيون (كلمة المرور محذوفة للتبسيط؛ راجع الدرس السابق)const users = [ { id: 1, name: 'المشرفة نوف', role: 'admin' }, { id: 2, name: 'المعلم سعد', role: 'teacher' }, { id: 3, name: 'الطالب يزيد', role: 'student' },];app.post('/api/login', (req, res) => { const u = users.find(x => x.id === req.body.id); if (!u) return res.status(401).json({ error: 'غير معروف' }); res.json({ token: jwt.sign({ sub: u.id, role: u.role }, SECRET, { expiresIn: '1h' }) });}); function auth(req, res, next) { try { req.user = jwt.verify((req.headers.authorization || '').slice(7), SECRET); next(); } catch { res.status(401).json({ error: 'سجّل الدخول' }); }} const ads = []; function allow(...roles) { return (req, res, next) => roles.includes(req.user.role) ? next() : res.status(403).json({ error: 'ممنوع' });} app.get('/api/ads', auth, (req, res) => res.json(ads)); app.post('/api/ads', auth, allow('teacher', 'admin'), (req, res) => { const ad = { id: ads.length + 1, text: req.body.text, authorId: req.user.sub }; ads.push(ad); res.status(201).json(ad);}); app.delete('/api/ads/:id', auth, (req, res) => { const i = ads.findIndex(a => a.id === Number(req.params.id)); if (i < 0) return res.status(404).json({ error: 'غير موجود' }); if (ads[i].authorId !== req.user.sub && req.user.role !== 'admin') return res.status(403).json({ error: 'ليس إعلانك' }); ads.splice(i, 1); res.sendStatus(204);}); app.listen(3000);