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

المصادقة بـ JWT

تسجيل، ودخول، ومسارات لا يصلها إلا صاحب الرمز ⏱ نحو 50 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تميّز بين المصادقة والتفويض ورمزي 401 و403.
  • تجزّئ كلمات المرور وتتحقق منها بـ bcrypt.
  • تصدر رموز JWT وتتحقق منها، وتعرف ما يحميه التوقيع وما لا يحميه.
  • تحمي المسارات بوسيط، وتختار مكانًا آمنًا للرمز في الواجهة.

١المصادقة والتفويض

سؤالان يطرحهما كل خادم على كل طلب:

السؤالالرد عند الفشل
المصادقة Authenticationمن أنت؟ (هل أثبتّ هويتك؟)401 Unauthorized
التفويض Authorizationهل يحق لك هذا الفعل؟ (مشرف؟ صاحب العنصر؟)403 Forbidden

في هذا الدرس المصادقة: التسجيل، وتسجيل الدخول، وإثبات الهوية في كل طلب بعده. وفي الدرس التالي التفويض.

٢لا تحفظ كلمة المرور أبدًا

إن سُرقت قاعدة البيانات وفيها كلمات المرور نصًّا، انكشفت حسابات المستخدمين (وغالبًا حساباتهم في مواقع أخرى). لذلك نحفظ تجزئة Hash: دالة باتجاه واحد، تحوّل كلمة المرور إلى نص لا يمكن عكسه. وعند الدخول نجزّئ ما كتبه المستخدم ونقارن.

bcrypt مصممة لكلمات المرور: بطيئة عمدًا (لتصعّب التخمين)، وتضيف ملحًا Salt عشوائيًّا، فكلمتا المرور المتطابقتان تعطيان تجزئتين مختلفتين:

const express = require('express');const bcrypt = require('bcryptjs');const app = express(); app.get('/demo', async (req, res) => {  const h1 = await bcrypt.hash('Pass1234', 10);  const h2 = await bcrypt.hash('Pass1234', 10);  res.json({    h1, h2,    same: h1 === h2,    check1: await bcrypt.compare('Pass1234', h1),    check2: await bcrypt.compare('pass1234', h1),  });}); app.listen(3000);

التجزئتان مختلفتان رغم أن كلمة المرور واحدة (بسبب الملح)، ومع ذلك تعرف compare أن الأولى صحيحة والثانية (حرف صغير) خاطئة. الرقم 10 هو «التكلفة»: كل زيادة بواحد تضاعف الوقت. أعد الإرسال في المختبر لترى تجزئات جديدة.

🖥 التثبيت
$ npm install bcryptjs jsonwebtoken dotenv

٣JWT: بطاقة هوية موقّعة

بعد الدخول، كيف يعرف الخادم صاحب الطلبات التالية دون أن يطلب كلمة المرور كل مرة؟ يعطيه رمزًا Token يرسله مع كل طلب. JWT (JSON Web Token) رمز من ثلاثة أجزاء بينها نقاط:

الجزءمحتواهمثال بعد فك الترميز
Headerنوع الرمز وخوارزمية التوقيع{"alg":"HS256","typ":"JWT"}
Payloadالبيانات (الادعاءات Claims){"sub":7,"email":"…","exp":1735…}
Signatureتوقيع الجزأين بسرّ لا يعرفه إلا الخادم—
const express = require('express');const jwt = require('jsonwebtoken');const app = express();const SECRET = 'سر-التجربة'; app.get('/sign', (req, res) => {  const token = jwt.sign({ sub: 7, role: 'student' }, SECRET, { expiresIn: '15m' });  const [h, p] = token.split('.');  res.json({    token,    header: JSON.parse(Buffer.from(h, 'base64url').toString()),    payload: JSON.parse(Buffer.from(p, 'base64url').toString()),  });}); app.get('/tamper', (req, res) => {  const token = jwt.sign({ sub: 7, role: 'student' }, SECRET);  const [h, , s] = token.split('.');  const fake = Buffer.from(JSON.stringify({ sub: 7, role: 'admin' })).toString('base64url');  try {    jwt.verify(`${h}.${fake}.${s}`, SECRET);    res.json({ ok: true });  } catch (err) {    res.status(401).json({ error: err.message });  }}); app.listen(3000);

في /sign قرأنا الحمولة دون السر، ورأينا iat (وقت الإصدار) و exp (الانتهاء). وفي /tamper غيّرنا الدور إلى admin فرفض verify الرمز: invalid signature.

٤التدفق الكامل: تسجيل، ودخول، ومسار محمي

هذا خادم مصادقة كامل. الوسيط auth يقرأ الرمز من الترويسة Authorization: Bearer <token>، ويتحقق منه، ويضع صاحبه في req.user:

const express = require('express');const bcrypt = require('bcryptjs');const jwt = require('jsonwebtoken'); const app = express();app.use(express.json()); const SECRET = process.env.JWT_SECRET;   // من ملف .env، لا يُكتب في الكودconst users = [];                          // في المشروع الحقيقي: نموذج User في قاعدة البيانات app.post('/api/register', async (req, res) => {  const { email, password } = req.body;  if (!email || !password || password.length < 8) {    return res.status(400).json({ error: 'البريد وكلمة مرور من 8 أحرف على الأقل' });  }  if (users.find(u => u.email === email)) return res.status(409).json({ error: 'البريد مسجّل' });  const hash = await bcrypt.hash(password, 10);  users.push({ id: users.length + 1, email, hash });  res.status(201).json({ id: users.length, email });}); app.post('/api/login', async (req, res) => {  const { email, password } = req.body;  const user = users.find(u => u.email === email);  if (!user || !(await bcrypt.compare(password, user.hash))) {    return res.status(401).json({ error: 'البريد أو كلمة المرور غير صحيحة' });  }  const token = jwt.sign({ sub: user.id, email: user.email }, SECRET, { expiresIn: '1h' });  res.json({ token });}); // الوسيط الحارس: يتحقق من الرمز ويضع صاحبه في req.userfunction auth(req, res, next) {  const header = req.headers.authorization || '';  const token = header.startsWith('Bearer ') ? header.slice(7) : null;  if (!token) return res.status(401).json({ error: 'سجّل الدخول أولًا' });  try {    req.user = jwt.verify(token, SECRET);    next();  } catch (err) {    res.status(401).json({ error: 'رمز غير صالح أو منتهي' });  }} app.get('/api/profile', auth, (req, res) => {  res.json({ message: 'مرحبًا ' + req.user.email, userId: req.user.sub });}); app.listen(3000);

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

جرّبه بنفسك: سجّل، ثم ادخل، ثم اضغط «🔑 استخدم هذا الرمز» ليُضاف إلى الترويسات، ثم اطلب /api/profile:

🧪 مختبر Expressعدّل الملفات، ثم أرسل طلبًا يدويًّا أو افتح الرابط في المتصفح المصغّر. الخادم يعمل داخل صفحتك.

٥أين تحفظ الواجهة الرمز؟

المكانالمزاياالمخاطر
ذاكرة React (حالة)لا يصل إليه كود خارجي بسهولةيضيع عند تحديث الصفحة
localStorageبسيط ويبقى بعد الإغلاقأي سكربت محقون (XSS) يقرؤه ويسرقه
كوكي httpOnly; Secure; SameSiteلا يقرؤه JavaScript إطلاقًا، ويُرسل تلقائيًّايحتاج حماية CSRF وإعدادًا إضافيًّا

الكوكي httpOnly هو الخيار الأكثر أمانًا في التطبيقات الحقيقية. بدل res.json({ token }) نكتب:

res.cookie('token', token, {  httpOnly: true,        // لا يقرؤه document.cookie  secure: true,          // عبر HTTPS فقط  sameSite: 'strict',    // لا يُرسل من مواقع أخرى  maxAge: 60 * 60 * 1000 // ساعة});res.json({ ok: true }); // وفي الوسيط (مع cookie-parser):const token = req.cookies.token;

توقّع وتحقّق

لماذا نجزّئ كلمات المرور؟

اتجاه واحد.

أي جزء في JWT يمنع التلاعب بالحمولة؟

السر.

الرمز يُرسل عادة في الترويسة:

Bearer.

مستخدم بلا رمز طلب مسارًا محميًّا. الرد المناسب:

غير مصادَق.

الخلاصة

تحدٍّ برمجي: مذكراتي الخاصة

متقدم

خادم مذكرات: كل مستخدم يرى مذكراته فقط بعد تسجيل الدخول. التسجيل جاهز، أكمل الباقي.

المطلوب:
  • POST /api/login يتحقق بـ bcrypt.compare ويرد بـ {"token": …} موقّعًا بـ process.env.JWT_SECRET وفيه sub = رقم المستخدم؛ أو 401.
  • وسيط auth يرفض الطلب بلا رمز صالح بالحالة 401.
  • POST /api/notes (محمي) يضيف مذكرة {"text": …} باسم صاحب الرمز ويرد بـ 201.
  • GET /api/notes (محمي) يرد بمذكرات صاحب الرمز فقط.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. POST /api/register {"email": "a@x.sa", "password": "aaaa1111"}
  2. POST /api/register {"email": "b@x.sa", "password": "bbbb2222"}
  3. POST /api/login {"email": "a@x.sa", "password": "خطأ"}
  4. POST /api/login {"email": "a@x.sa", "password": "aaaa1111"}
  5. POST /api/login {"email": "b@x.sa", "password": "bbbb2222"}
  6. POST /api/notes {"text": "مذكرة أ"}
  7. POST /api/notes {"text": "مذكرة أ"} 🔑
  8. POST /api/notes {"text": "مذكرة ب"} 🔑
  9. GET /api/notes 🔑
  10. GET /api/notes 🔑
  11. GET /api/notes 🔑
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • كلمة مرور خاطئة ← 401
  • الدخول الصحيح يعيد token نصًّا
  • إضافة مذكرة بلا رمز ← 401
  • إضافة مذكرة بالرمز ← 201
  • المستخدم الأول يرى مذكرته فقط
  • المستخدم الثاني يرى مذكرته فقط
  • رمز غير صالح ← 401
  • يستخدم bcrypt.compare و jwt.sign و jwt.verify
  • السر من process.env وليس مكتوبًا في الكود

💡 تلميح 1
jwt.sign({ sub: user.id }, process.env.JWT_SECRET)، ثم في الوسيط req.user = jwt.verify(…) داخل try.
💡 تلميح 2
فلتر المذكرات: n.userId === req.user.sub.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const bcrypt = require('bcryptjs');const jwt = require('jsonwebtoken');require('dotenv').config(); const app = express();app.use(express.json()); const users = [];const notes = [];   // { userId, text } app.post('/api/register', async (req, res) => {  const user = { id: users.length + 1, email: req.body.email, hash: await bcrypt.hash(req.body.password, 8) };  users.push(user);  res.status(201).json({ id: user.id });}); app.post('/api/login', async (req, res) => {  const user = users.find(u => u.email === req.body.email);  if (!user || !(await bcrypt.compare(req.body.password || '', user.hash))) {    return res.status(401).json({ error: 'بيانات الدخول غير صحيحة' });  }  res.json({ token: jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' }) });}); function auth(req, res, next) {  const token = (req.headers.authorization || '').replace('Bearer ', '');  try {    req.user = jwt.verify(token, process.env.JWT_SECRET);    next();  } catch {    res.status(401).json({ error: 'سجّل الدخول' });  }} app.post('/api/notes', auth, (req, res) => {  const note = { userId: req.user.sub, text: req.body.text };  notes.push(note);  res.status(201).json(note);}); app.get('/api/notes', auth, (req, res) => {  res.json(notes.filter(n => n.userId === req.user.sub));}); app.listen(3000);
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

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