طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 16 · الوحدة 6: المصادقة والتفويض

التفويض والأدوار و OAuth

الأدوار، والملكية، وتسجيل الدخول بحساب خارجي ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تميّز المصادقة من التفويض، و401 من 403.
  • تكتب وسيط أدوار قابلًا لإعادة الاستخدام.
  • تتحقق من ملكية المورد وتتجنب IDOR.
  • تشرح خطوات OAuth 2.0 ودور client_secret و state.

١بعد معرفة «من أنت»: ماذا يحق لك؟

المصادقة أثبتت هوية المستخدم. أما التفويض فيقرر: هل يحق له هذا الفعل على هذا المورد؟ وأشهر طريقتين:

الطريقةالفكرةمثال
الأدوار RBACلكل مستخدم دور، ولكل دور صلاحياتالمعلم يرصد الدرجات، والطالب لا
الملكية Ownershipالمستخدم يعدّل ما يملكه فقطالطالب يعدّل واجبه هو لا واجب زميله
الصلاحيةadminteacherstudent
عرض المقررات✓✓✓
رصد الدرجات✓✓✗
حذف المستخدمين✓✗✗

٢وسيط الأدوار

نكتب دالة تصنع وسيطًا: 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:

  1. تطبيقك يحوّل المستخدم إلى صفحة المزوّد ومعه client_id و redirect_uri و state عشوائي.
  2. المستخدم يسجّل الدخول عند المزوّد ويوافق على مشاركة بريده واسمه.
  3. المزوّد يعيده إلى redirect_uri في تطبيقك ومعه رمز مؤقت code و state نفسه.
  4. خادمك (لا المتصفح) يستبدل الرمز المؤقت برمز وصول، مستخدمًا client_secret السري.
  5. خادمك يطلب بيانات المستخدم من المزوّد، ثم ينشئ له جلسة أو 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؟

قيمة عشوائية.

الخلاصة

تحدٍّ برمجي: لوحة الإعلانات

متقدم

إعلانات الكلية: الجميع يقرأ، والمعلم والمشرف ينشران، والناشر وحده (أو المشرف) يحذف إعلانه. المصادقة جاهزة.

المطلوب:
  • اكتب allow(...roles) يرد بـ 403 لمن ليس دوره في القائمة.
  • GET /api/ads لأي مستخدم مسجّل (وبلا رمز: 401).
  • POST /api/ads للمعلم والمشرف فقط؛ يحفظ authorId من الرمز ويرد بـ 201.
  • DELETE /api/ads/:id: صاحب الإعلان أو المشرف يحذف (204)، وغيرهما 403.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. POST /api/login {"id": 1}
  2. POST /api/login {"id": 2}
  3. POST /api/login {"id": 3}
  4. GET /api/ads
  5. POST /api/ads {"text": "اختبار قصير الأحد"} 🔑
  6. POST /api/ads {"text": "اختبار قصير الأحد"} 🔑
  7. POST /api/ads {"text": "إعلان الإدارة"} 🔑
  8. GET /api/ads 🔑
  9. DELETE /api/ads/1 🔑
  10. DELETE /api/ads/2 🔑
  11. DELETE /api/ads/1 🔑
  12. GET /api/ads 🔑
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • بلا رمز ← 401
  • الطالب لا ينشر ← 403
  • المعلم ينشر ← 201
  • … ويُحفظ authorId من الرمز (2)
  • الطالب يقرأ الإعلانين
  • الطالب لا يحذف إعلان المعلم ← 403
  • المعلم لا يحذف إعلان المشرف ← 403
  • المشرف يحذف إعلان المعلم ← 204
  • بقي إعلان واحد

💡 تلميح 1
allow دالة تعيد دالة وسيطة؛ ضعها بعد auth في POST.
💡 تلميح 2
الحذف لا يحتاج allow: افحص 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);
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس