المصادقة بـ 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);
JWT_SECRET=غيّرني-إلى-نص-طويل-عشوائي-جدًّاالطلب الخامس أرسل الرمز الذي عاد من الطلب الثالث، فعرف الخادم صاحبه دون كلمة مرور. لاحظ أن رسالة الدخول الفاشل لا تقول أيّهما الخطأ (البريد أم كلمة المرور)، حتى لا يعرف المهاجم أي البريدات مسجّلة.
جرّبه بنفسك: سجّل، ثم ادخل، ثم اضغط «🔑 استخدم هذا الرمز» ليُضاف إلى الترويسات، ثم اطلب /api/profile:
٥أين تحفظ الواجهة الرمز؟
| المكان | المزايا | المخاطر |
|---|---|---|
| ذاكرة 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.مستخدم بلا رمز طلب مسارًا محميًّا. الرد المناسب:
الخلاصة
- لا كلمات مرور نصية:
bcrypt.hashعند التسجيل، وbcrypt.compareعند الدخول. JWT = Header.Payload.Signature؛ الحمولة مقروءة، والتوقيع يمنع التعديل.jwt.sign(payload, SECRET, { expiresIn })وjwt.verify(token, SECRET).- الوسيط الحارس يضع
req.userأو يرد بـ 401. - السر في .
env، والرمز قصير العمر، والأكثر أمانًا كوكيhttpOnly.
تحدٍّ برمجي: مذكراتي الخاصة
متقدمخادم مذكرات: كل مستخدم يرى مذكراته فقط بعد تسجيل الدخول. التسجيل جاهز، أكمل الباقي.
POST /api/loginيتحقق بـbcrypt.compareويرد بـ{"token": …}موقّعًا بـprocess.env.JWT_SECRETوفيهsub= رقم المستخدم؛ أو 401.- وسيط
authيرفض الطلب بلا رمز صالح بالحالة 401. POST /api/notes(محمي) يضيف مذكرة{"text": …}باسم صاحب الرمز ويرد بـ 201.GET /api/notes(محمي) يرد بمذكرات صاحب الرمز فقط.
👀 اعرض الاستجابات المطلوبة
POST /api/register{"email": "a@x.sa", "password": "aaaa1111"}POST /api/register{"email": "b@x.sa", "password": "bbbb2222"}POST /api/login{"email": "a@x.sa", "password": "خطأ"}POST /api/login{"email": "a@x.sa", "password": "aaaa1111"}POST /api/login{"email": "b@x.sa", "password": "bbbb2222"}POST /api/notes{"text": "مذكرة أ"}POST /api/notes{"text": "مذكرة أ"} 🔑POST /api/notes{"text": "مذكرة ب"} 🔑GET /api/notes🔑GET /api/notes🔑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);
JWT_SECRET=challenge-secret-2025-very-long