ما هو JWT
جزء من قسم المصادقة والأمان في رحلة Node.js على Coddy. الدرس 14 من 24.
تحتاج الجلسة إلى استعلام عن قاعدة البيانات مع كل طلب. يحمل JWT (رمز ويب JSON) المعلومات بنفسه: ثلاثة أجزاء تفصل بينها نقاط. يُكتب كل جزء بترميز base64url، وهي طريقة لكتابة البايتات باستخدام الأحرف والأرقام و- و_. يحدد الترويس الخوارزمية، ويحمل المحتوى الادعاءات (sub لتحديد صاحب الرمز، وexp لتحديد موعد انتهاء صلاحيته)، ويثبت التوقيع أن الخادم هو من أنشأه.
ترميز base64url هو ترميز وليس تشفيرًا. يمكن لأي شخص يحمل رمزًا قراءة محتواه:
const payload = token.split('.')[1];
JSON.parse(Buffer.from(payload, 'base64url').toString());
// { sub: '42', name: 'Lin', exp: 1767225600 }تُجري decodeJwt(token) من حزمة JWT المسماة jose الأمر نفسه. فهي تقرأ المحتوى من دون التحقق من التوقيع، وتُصدر خطأً عندما لا يكون النص رمز JWT. لذا لا تضع سرًا في المحتوى أبدًا، ولا تتخذ قرارًا أبدًا بناءً على ادعاء أعادتْه لك decodeJwt؛ إذ يمكن لأي شخص كتابة أي ادعاء.
التحدي
سهليضيف هذا الفصل طريقة ثانية لتسجيل الدخول باستخدام الرموز المميزة، للتطبيقات التي تستدعي واجهة API الخاصة بك. يبدأ بنسخة مصغّرة من الخادم، تتضمن إنشاء الحساب وتسجيل الدخول، لكن من دون جلسات أو ملاحظات. يحمل JWT بياناته بداخله، ويمكن لأي شخص قراءتها. أنشئ مسارًا يعرض ما يقوله الرمز المميز، وأغلق صفحة المسؤولين التي تثق بكل ما يدّعيه الرمز المميز.
- في
server.js، اكتبGET /inspect. يقرأ هذا المسار رمزًا مميزًا من سلسلة الاستعلام، كما فيGET /inspect?token=eyJ...، ويعيد الادعاءات الموجودة بداخله بتنسيق JSON. اقرأها باستخدامdecodeJwt، وهي مستوردة بالفعل. إذا كان محتوى الرمز المميز هو{"sub": "lin", "plan": "free"}، فإن المسار يعيد بالضبط{"sub": "lin", "plan": "free"}. - تُطلق
decodeJwtخطأً عندما لا يكون النص JWT. التقط الخطأ واستجب بحالة 400 وبـ{"error": "Not a JWT"}. على سبيل المثال، ينتج عنGET /inspect?token=helloحالة 400 هذه. - ألقِ نظرة على
GET /admin. فهو يقرأ الرمز المميز باستخدامdecodeJwtويعرض سرًا عندما يقول الرمز المميز"admin": true. لكنdecodeJwtلا يتحقق أبدًا من التوقيع، لذا يستطيع أي شخص كتابة رمز مميز يقول"admin": true. لا يستطيع هذا الخادم التحقق من التوقيعات بعد. لذلك اجعلGET /adminيستجيب بحالة 403 وبـ{"error": "Admins only"}لكل طلب، أيًا يكن الرمز المميز الذي يتلقاه. تعني 403 Forbidden «لا يُسمح لك برؤية هذا». يعيد الفصل 4 فتح صفحة المسؤولين، لكن للمسؤولين الحقيقيين فقط.
جرّب ذلك: اضغط «تشغيل». يبدأ شريط الطلب بـ GET /admin?token= متبوعًا بهذا الرمز المميز: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYWxsb3J5IiwiYWRtaW4iOnRydWV9.c2lnbmF0dXJl كتبه مهاجم يُدعى mallory يدويًا، من دون مفتاح سري. أرسله قبل أن تغيّر أي شيء: يعيد الإصدار الأولي {"secret": "The vault key is 42"}. بعد التغيير، اضغط «تشغيل» مجددًا وأرسل الطلب نفسه: الحالة 403 و{"error": "Admins only"}. أرسل GET /inspect?token= متبوعًا بالرمز المميز نفسه: {"sub": "mallory", "admin": true}، مقروءًا من دون أي مفتاح. أرسل GET /inspect?token=hello: الحالة 400 و{"error": "Not a JWT"}.
جرّب بنفسك
import express from 'express';
import { decodeJwt } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.use(express.json());
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
return res.status(401).json({ error: 'Wrong username or password' });
}
res.json({ username: user.username });
});
// يعرض محتوى الرمز: GET /inspect?token=...
app.get('/inspect', (req, res) => {
res.status(501).json({ error: 'Not written yet' });
});
// صفحة المسؤول.
app.get('/admin', (req, res) => {
const claims = decodeJwt(String(req.query.token ?? ''));
if (claims.admin === true) {
return res.json({ secret: 'The vault key is 42' });
}
res.status(403).json({ error: 'Admins only' });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.
جميع دروس المصادقة والأمان
تدرّب بنفسك: Playground لـ Express