الإقليم ٧ — الهويّةُ بلا حالة: bcrypt و JWT من الصفر
النبذة
هنا نحلّ أقدمَ بذرةٍ في المنهج. الإقليم ٠ قال: HTTP عديمُ الذاكرة — الخادمُ ينساك بمجرّد أن يُغلَق الاتصال، وهذا عمداً كي يتوسّع أفقياً (أيُّ نسخةٍ تخدم أيَّ طلب). ومع ذلك GET /me يجب أن يعرفك، والتصحيحُ يجب أن يسمح للمسجَّل فقط. كيف يعرفك خادمٌ بلا ذاكرةٍ في كل طلبٍ مستقلّ، دون أن يحفظ جلسةً لكل مستخدمٍ (ما يكسر انعدامَ الحالة)؟ ومسألةٌ ثانيةٌ موازية: كيف تخزّن كلمةَ مرورٍ بحيث لا يمنح اختراقُ القاعدةِ المهاجمَ كلماتِ مرور الجميع؟ مسألتان، نبنيهما من جذرهما — وستخرج منهما بفهمٍ يجعلك لا تخاف JWT أبداً.
اللغز المستفزّ
جزآن، اقعد مع كلٍّ قبل أن تقرأ:
أ — السرّ الذي يجب أن تتحقّق منه دون أن تستطيع استرجاعه. ستخزّن كلمات مرور آلافِ المتدرّبين. لو خزّنتَها كما هي (plaintext)، فأولُ تسريبٍ لقاعدتك يفضح الجميع — وأكثرُهم يعيدون استعمالَ الكلمة نفسها في بنوكهم وبريدهم. لكنك تحتاج أن تتحقّق: حين يسجّل الدخولَ بكلمةٍ، تقارنها بالمخزَّن. كيف تخزّن شيئاً تتحقّق منه لاحقاً، لكن لا تستطيع — ولا مَن يسرق قاعدتك — استرجاعَه؟
ب — الإثبات الذي يحمله الغريبُ ولا يستطيع تزويرَه. الخادمُ بلا ذاكرة. فبعد تسجيل الدخول، كيف يثبت العميلُ في كلِّ طلبٍ تالٍ "أنا يزيد، الدور USER" بحيث: (١) لا يحفظ الخادمُ شيئاً (يبقى عديمَ الحالة)، و(٢) لا يستطيع العميلُ تزوير الادّعاء (يكتب "الدور ADMIN")، و(٣) لا يستطيع طرفٌ ثالثٌ سرقتَه وإعادةَ استعماله بسهولة؟
كلا اللغزين عن اللاتماثل: شيءٌ سهلٌ في اتّجاهٍ مستحيلٌ في العكس. اقعد معهما.
الجزء الأول — كلمات المرور و bcrypt
ليش: التجزئة أحاديّةُ الاتّجاه، لا التشفير
التشفيرُ (encryption) قابلٌ للعكس بمفتاح — خطأٌ لكلمات المرور، لأن وجودَ مفتاحٍ يعني وجودَ بابٍ خلفيّ. ما تريده دالةُ تجزئةٍ أحاديّةُ الاتّجاه (hash): سهلةُ الحساب أماماً (كلمة → بصمة)، مستحيلةُ العكس (بصمة → كلمة). تخزّن البصمة. عند الدخول، تجزّئ المُدخَلَ وتقارن البصمتين. لا تسترجع شيئاً أبداً. حلَّ اللغز أ — لكن الشيطانُ في التفاصيل.
ليش لا تجزئةً سريعةً (مثل SHA-256)؟ لأنك تريدها بطيئةً عمداً
SHA-256 سريعةٌ جداً — وهذا عيبٌ هنا. المهاجمُ الذي سرق بصماتِك يجرّب مليارات التخمينات في الثانية على عتادٍ رخيص (أو يستعمل جداولَ قوس قزح: بصماتٌ محسوبةٌ مسبقاً لكلماتٍ شائعة). تريد دالةً بطيئةً ومكلفةً عمداً، فتخمينُ المهاجمِ يصير مؤلماً، بينما تحقّقُك الواحدُ عند الدخول يحتمل التأخيرَ البسيط.
كيف: bcrypt — ملحٌ + معاملُ كلفة
bcrypt دالةُ تجزئةٍ صُمِّمت لهذا:
- الملح (salt): قيمةٌ عشوائيةٌ فريدةٌ لكل كلمةِ مرور، تُدمَج قبل التجزئة وتُخزَّن مع البصمة. أثرُه: كلمتان متطابقتان تنتجان بصمتين مختلفتين (فلا يكشف المهاجمُ المستخدمين ذوي الكلمة نفسها)، وتبطل جداولُ قوس قزح كلّها (لأنها مبنيّةٌ على كلماتٍ بلا ملح).
- معاملُ الكلفة (cost / work factor): الرقم
10فيbcrypt.hash(password, 10)بمشروعك. يضبط عددَ الجولات أُسّياً (2^10)؛ كلّما زاد، أبطأ — تواكب به تسارعَ العتاد عبر السنين. مقايضةٌ واعية: أمنٌ أعلى مقابل زمنٍ أطول لكل تجزئة.
الوصلة بالإقليم ٢ (لماذا async): هذه الكلفةُ المتعمّدة تعني أن التجزئة عملٌ محسوبٌ ثقيل. لو جرت متزامنةً على خيط الحلقة لجمّدت الخادمَ عند كل تسجيل. لهذا bcrypt يجري على مجمّع خيوط libuv ويُنتظَر بـ await بلا حجبِ الحلقة. تجزئةُ الإقليم ٧ هي مثالُ الإقليم ٢ الحيّ.
كيف: التحقّق (دون استرجاع)
tsconst hash = await bcrypt.hash(password, 10); // التسجيل: خزّن hash (يحوي الملح) const ok = await bcrypt.compare(candidate, hash); // الدخول: أعِد التجزئةَ بنفس الملح وقارن
compare يستخرج الملحَ من البصمة المخزّنة، يجزّئ المُدخَل به، ويقارن بزمنٍ ثابتٍ (constant-time — لا يُفشي طولَ التطابق عبر توقيتِ المقارنة، حمايةً من هجمات التوقيت). لا decrypt — لا وجودَ له. تدفّقُ الدخول: ابحث عن المستخدم بـ email/username (Prisma، ٥)، bcrypt.compare، فإن صحّ أصدِر إثباتَ هويّة. ما هذا الإثبات؟ الجزء الثاني.
الجزء الثاني — JWT من الصفر
ليش: لماذا token لا جلسةٌ على الخادم؟
طريقتان لتذكّر "من المُرسِل" فوق بروتوكولٍ بلا ذاكرة:
- جلسةٌ على الخادم (sessions): عند الدخول، يولّد الخادمُ
session_idعشوائياً، يخزّنsession_id → userفي ذاكرته/Redis، ويعطي العميلَ المعرّفَ فقط. كلُّ طلبٍ يحمل المعرّف، فيبحث الخادمُ عنه. المشكلة: هذه حالةٌ على الخادم — تكسر انعدامَ الحالة (٠)، وتتطلّب مخزَناً مشتركاً بين كل النسخ (وإلّا "تلتصق" بنسخةٍ واحدة، فينهار التوسّعُ الأفقيّ الذي تعرفه). - رمزٌ محمولٌ موقَّع (token): اجعل الإثباتَ يحمل ادّعاءاتِه بنفسه (من، أيُّ دور، متى ينتهي)، موقَّعاً بسرٍّ لا يملكه إلا الخادم. كلُّ طلبٍ يحمله؛ الخادمُ يتحقّق من التوقيع بسرّه ويصدّق الادّعاءات بلا أي بحثٍ أو تخزين. يبقى عديمَ الحالة، وأيُّ نسخةٍ تتحقّق فوراً. هذا JWT.
مقايضةٌ صادقة: الـ token صعبُ الإبطال قبل انتهائه (لا سجلَّ مركزياً تحذفه منه)، وحجمُه أكبر، ويُعاد إرسالُه كلَّ طلب. حلولٌ (انتهاءٌ قصير + refresh tokens) بذرةٌ لما بعد. لكن لانعدام الحالة والتوسّع، الـ token هو الجواب.
كيف: بنية JWT — ثلاثةُ أجزاءٍ بـ base64url
codeheader . payload . signature eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJ1MSIsInJvbGUiOiJVU0VSIn0 . 3Tb9aP...
- header:
{"alg":"HS256","typ":"JWT"}— أيُّ خوارزميةِ توقيع. - payload: الادّعاءات (claims) —
sub(هويّةُ المستخدم)،role،iat(وقت الإصدار)،exp(وقت الانتهاء). بياناتُك. - signature:
HMAC-SHA256( base64url(header) + "." + base64url(payload), SECRET ). توقيعٌ على الجزأين الأولين بالسرّ.
كلُّ جزءٍ مُرمَّزٌ بـ base64url (تمثيلٌ نصّيٌّ آمنٌ للـ URL، ليس تشفيراً).
⚠️ الفهمُ الذي يفصل المحترفَ من غيره: التوقيع ≠ التشفير
payload مُرمَّزٌ بـ base64url، لا مُشفَّر. أيُّ أحدٍ يفكّه ويقرؤه. التوقيعُ لا يُخفي شيئاً — يضمن فقط السلامةَ والأصالة: أن المحتوى لم يُزوَّر ولم يُعدَّل بعد التوقيع. لأن أيَّ تغييرٍ في header/payload يُبطل التوقيعَ (الذي لا يُحسَب إلا بالسرّ). النتيجة العمليّة: لا تضع سرّاً قطّ في payload (لا كلماتِ مرور، لا بياناتٍ حسّاسة) — ضع هويّةً ودوراً وانتهاءً فقط. من يخلط التوقيعَ بالتشفير يبني ثغرةً.
كيف تمنع التزوير إذن؟ لو غيّر العميلُ "role":"USER" إلى "ADMIN"، تغيّر payload، فلم يعد التوقيعُ يطابق — والعميلُ لا يستطيع حسابَ توقيعٍ جديدٍ صحيحٍ لأنه لا يملك SECRET. الخادمُ يعيد حسابَ التوقيع بسرّه ويقارن؛ لا يطابق → رفض. هذا حلُّ اللغز ب: إثباتٌ محمولٌ، قابلُ القراءة، مستحيلُ التزوير بلا السرّ.
السرّ (JWT_SECRET) — كلُّ الأمن هنا
التوقيعُ والتحقّقُ يعتمدان على SECRET واحد. من يملكه يصنع رموزاً صالحةً لأي مستخدمٍ وأي دور — أي يصير أيَّ أحد. لهذا يأتي من process.env.JWT_SECRET (٢)، طويلاً عشوائياً، لا يُلتزَم في git أبداً (تذكّر Fix: Restored .env.example after security breach في تاريخ مشروعك — درسٌ حيّ على ثمنِ تسريب الأسرار).
كيف: الهويّةُ كـ middleware (تركيبٌ مع الإقليم ٣)
"كلُّ حدٍّ يحتاج بوّابة" (٦) — والهويّةُ بوّابةُ حدٍّ على المُرسِل. حلقتان في السلسلة:
ts// authenticate: من أنت؟ تحقّق من الرمز أو 401 function authenticate(req, res, next) { const header = req.headers.authorization; // "Bearer <token>" const token = header?.startsWith("Bearer ") ? header.slice(7) : null; if (!token) return res.status(401).json({ success: false, message: "no token" }); try { req.user = verifyJWT(token, process.env.JWT_SECRET); // { sub, role, ... } next(); } catch { return res.status(401).json({ success: false, message: "invalid token" }); // مزوَّر/منتهٍ } } // authorize: عرفتُك، لكن هل يُسمح لك؟ أو 403 function authorize(...roles) { return (req, res, next) => roles.includes(req.user.role) ? next() : res.status(403).json({ success: false, message: "forbidden" }); }
استعمال: app.get("/me", authenticate, meController) و app.post("/curricula", authenticate, authorize("ADMIN"), createCurriculum).
فرقُ 401 و 403 (٠) يتجسّد: authenticate يردّ 401 (من أنت؟ لم تُثبت)؛ authorize يردّ 403 (عرفتُك، ممنوع). والسلسلةُ تكتمل: helmet → cors → json → rate-limit → validate(Zod) → authenticate → authorize → controller → service(Prisma).
الحاملُ: ترويسةٌ أم كوكي؟ (مشروعك يملك الاثنين)
أين يحفظ العميلُ الرمزَ ويرسله؟ مقايضةٌ صادقة:
Authorization: Bearer <token>: بسيطٌ لتطبيقات الصفحة الواحدة (SPA)؛ لكن لو خُزِّن في JS (localStorage) فثغرةُ XSS قد تسرقه.- كوكي
httpOnly: لا يصلها JS (تقاوم سرقةَ XSS)، يرسلها المتصفّحُ تلقائياً؛ لكن تحتاج حمايةَ CSRF. (لهذا في تبعيّات مشروعكcookie-parser— الخيارُ متاح.)
مشروعك يميل لترويسة Bearer (README)، ويملك cookie-parser لو اختار الكوكي. لا حلَّ مثاليّ — قرارٌ تملكه الآن بفهمِ مقايضته.
اللغز / البناء من الصفر
أدواتك: Node و crypto المدمجة (لـ HMAC) وBuffer (لـ base64url). ممنوع jsonwebtoken في بناء JWT — تبنيه بيدك.
اللغز أ — JWT بأيديك (sign + verify). اكتب دالتين:
signJWT(payload, secret): ابنِ header، رمّز header و payload بـ base64url (Buffer.from(JSON.stringify(x)).toString("base64url"))، احسب التوقيعcrypto.createHmac("sha256", secret).update(h + "." + p).digest("base64url")، وأعِدh.p.s.verifyJWT(token, secret): فُكّ الأجزاء، أعِد حسابَ التوقيع وقارنه (بزمنٍ ثابتٍ عبرcrypto.timingSafeEqual)، وافحصexp. أعِد payload أو ارمِ خطأً.
ثم أثبِت ثلاثاً: (١) فُكّ payload بلا السرّ (base64url decode) واقرأه — برهانٌ أنه ليس مُشفَّراً، فلا تضع فيه سرّاً. (٢) اقلب بايتةً في payload وأعد التحقّق — يفشل (كشفُ التزوير). (٣) حاول صكَّ رمزٍ بدور ADMIN بلا السرّ — مستحيلٌ أن تُنتِج توقيعاً صحيحاً؛ ثم بالسرّ — ينجح (يثبت أن السرّ هو كلُّ شيء).
اللغز ب — bcrypt حقيقيٌّ، لاحظ خصائصه. جزّئ كلمةً، ثم جزّئ نفسَها مرّةً أخرى — لاحظ أن البصمتين مختلفتان (الملح). تحقّق بـ compare أن كلتيهما تطابقان الكلمةَ الأصلية، وأن كلمةً خاطئةً تفشل. لاحظ أنهما await (يجريان على المجمّع، ٢). جرّب رفعَ معاملِ الكلفة ولاحظ الزمنَ يطول أُسّياً — تشعر بالمقايضة بيدك.
اللغز ج — بوّابةُ الهويّة (تركيبٌ مع ٣ و٦). على tiny-express حقّك، اكتب authenticate وauthorize(...roles) باستعمال verifyJWT حقّك. احمِ GET /me بـ authenticate، وPOST /admin-only بـ authenticate, authorize("ADMIN"). اختبِر المصفوفةَ كاملة: بلا رمزٍ (401)، رمزٌ صالح (مرور)، رمزٌ مزوَّر/معدَّل (401)، رمزٌ منتهٍ (401)، رمزٌ صالحٌ بدورٍ خاطئٍ على مسار الأدمن (403). افهم لماذا كلٌّ 401 أو 403 لا غيره (٠).
اللغز د — تدفّقُ الدخول الكامل (يجمع الجزأين). اربط: register (Zod → bcrypt.hash → Prisma.create) ثم login (Prisma.findUnique بالبريد → bcrypt.compare → signJWT عند النجاح، أو 401) ثم GET /me (authenticate → أعِد req.user). هذا مصغّرُ نظامِ هويّة مشروعك كاملاً، بلبناتٍ بنيتَها كلَّها بيدك.
لا تنتقل قبل أن تبني signJWT/verifyJWT بيدك وتثبت الثلاثَ في اللغز (أ). من بنى JWT من HMAC بنفسه لا يخاف نظامَ مصادقةٍ بعدها أبداً.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- تحت: يحلّ بذرةَ انعدام الحالة (٠): bcrypt يخزّن سرّاً يُتحقَّق منه ولا يُسترجَع (تجزئةٌ بطيئةٌ مملَّحة، تجري على مجمّع libuv — ٢)؛ وJWT إثباتٌ محمولٌ موقَّعٌ بـ HMAC يبقي الخادمَ عديمَ الحالة ويمنع التزويرَ بلا السرّ. والهويّةُ حلقتان في سلسلة Express (٣)، تطبيقاً لـ"كلُّ حدٍّ بوّابة" (٦).
- العقدة الجديدة: الثقةُ بلا حالةٍ ولا استرجاع — اللاتماثلُ (سهلٌ أماماً، مستحيلٌ عكساً/تزويراً) أساسُ كلٍّ من تخزينِ كلمةِ المرور وإثباتِ الهويّة. والتوقيعُ ليس تشفيراً.
- فوق:
authenticate/authorizeتحرسان المتحكّماتِ في التدفّق الكامل (٩)، وتفويضُ التصحيح ("مسجَّلٌ فقط يُصحَّح") يقف عليهما (١٠). و401/403 يكملان لغةَ الرموز (٠).
الأبواب المفتوحة: بنينا الخادمَ كاملاً: حدٌّ (HTTP)، آلة (Node)، سلسلة (Express)، حالة (SQL/Prisma)، بوّابتا (Zod/الهويّة). من يولّد كلَّ هذه الطلبات من الطرف الآخر، ويعرض الردود، ويحفظ الرمزَ ويرسله؟ العميل — React (٨). ثم نجمع الرحلةَ كلَّها في صورةٍ واحدة (٩).
الوصلة للواقع (نمط SelfLab)
تبعيّاتُ مشروعك تنكشف كنظامِ هويّةٍ كامل: bcrypt (التجزئة)، jsonwebtoken + @types/jsonwebtoken (الرموز، النسخةُ المكتبيّةُ لِما بنيتَه بيدك)، JWT_SECRET في .env.example (السرّ من البيئة)، cookie-parser (خيارُ الحامل)، express-rate-limit (يخنق تخمينَ كلمات المرور على login). والمتحكّمُ الحاليُّ يفعل password: await bcrypt.hash(password, 10) ويُسقِط password من select الردّ (٥) — لا تُسرَّب البصمةُ أبداً.
ما ينقص وصرتَ تبنيه: متحكّمُ login (findUnique → compare → sign)، ودالّتا authenticate/authorize كـ middleware، وحمايةُ GET /me وما بعده، واختيارُ الحامل (Bearer أم كوكي httpOnly). تملكها كلَّها الآن، بفهمِ كلِّ مقايضة.
البذرة التالية: الخادمُ مكتمل. لكن المتدرّبَ لا يكلّم curl — يكلّم شاشة. من يحوّل نقرةً على زرٍّ إلى fetch يحمل Authorization: Bearer، ويأخذ المظروفَ العائدَ فيرسم قائمةَ مناهجَ تتحدّث وحدها حين تتغيّر البيانات، بلا أن تلمس DOM بيدك؟ ننزل للإقليم ٨ — React — نغلق الدائرة من جهة العميل، بقدرِ ما يكشف تدفّقَ البيانات.