اذهب إلى المحتوى

نكمل في هذا المقال تطوير تطبيق الواجهة البرمجية REST الذي بدأناه في مقال سابق، حيث نضيف للتطبيق المميزات التالية:

  • Mongoose التي تسمح لنا بالعمل مع قاعدة البيانات MongoDB بدلًا من كائن DAO المقيم في الذاكرة.
  • استيثاق Authentication ﻹضافة إمكانيات منح اﻷذونات كي يتمكن مستثمرو الواجهة البرمجية من استخدام مفتاح تشفير ويب JSON Web Token للوصول بأمان إلى نقاط وصول الواجهة البرمجية.
  • اختبارات مؤتمتة: باستخدام إطار عمل الاختبارات Mocha، والمكتبة Chai والوحدة البرمجية SuperTest، للمساعدة في التحقق من المحدودية عند زيادة حجم الشيفرة وتغيّرها.

كما سنضيف تباعًا مكتبات للتحقق من صحة المدخلات ومكتبات خاصة بآمان التطبيق، ,وسنكتسب بعض الخبرة في التعامل مع مدير الحاويات Docker.

تثبيت MongoDB على هيئة حاوية

لنبدأ باستبدال قاعدة البيانات المقيمة في الذاكرة التي استخدمناها مسبقا بقاعدة بيانات حقيقة هي MongoDB. باﻹمكان طبعًا تثبيت القاعدة محليًا على جهازك، لكن ونظرًا للاختلافات بين بيئات التشغيل (إصدارات مختلفة لنظام التشغيل مثلًا) والتي قد تسبب بعض المشكلات، سنستخدم مدير الحاويات Docker وهو أداة معيارية حاليًا في صناعة البرمجيات. وكل ما عليك فعله هو تثبيت Docker ثم Docker Compose، وتأكد بعدها من التثبيت بتنفيذ اﻷمر docker -v على الطرفية كي ترى نسخة Docker Composer التي ثُبتت على جهازك.

لتشغيل MongoDBضمن جذر المشروع، سننشئ أولًا ملف YAML يُدعى docker.compose.yml يضم الشيفرة التالية:

version: '3'
services:
 mongo:
    image: mongo
    volumes:
     - ./data:/data/db
    ports:
     - "27017:27017"

يتيح Docker Composer تشغيل عدة حاويات في نفس الوقت باستخدام ملف تهيئة واحد. لهذا سنستخدمه اﻵن لتشغيل MongoDB دون أن نثبتها على الجهاز:

sudo docker-compose up -d

يشغّل اﻷمر up الحاوية المحددة، مصغيًا إلى منفذ MongoDB27017، بينما تفصل d- اﻷمر عن الطرفية ليعمل التطبيق مستقلًا، وستكون النتيجة مشابهة لما يلي إذا جرى كل شيء دون مشاكل:

Creating network "toptal-rest-series_default" with the default driver
Creating toptal-rest-series_mongo_1 ... done

تنشئ التعليمة السابقة أيضًا الملجد في جذر المشروع، لهذا عليك إضافة سطر بالمجلد data في الملف الذي ينتهي باللاحقة gitignore.

وعندما نريد إيقاف الحاوية التي تحتوي MongoDB، علينا فقط تنفيذ الأمر sudo docker -compose down:

Stopping toptal-rest-series_mongo_1 ... done
Removing toptal-rest-series_mongo_1 ... done
Removing network toptal-rest-series_default

هذا كل ما تحتاج معرفته لتشغيل MongoDB مع الواجهة البرمجية REST. تأكد اﻵن من تنفيذ اﻷمر sudo docker-compose up -d حتى تكون MongoDB جاهزة للاستخدام مع التطبيق.

استخدام Mongoose للوصول إلى MongoDB

نستخدم مكتبة نمذجة البيانات Mongoose في الاتصال مع قاعدة البيانات MongoDB. وعلى الرغم من سهول استخدام Mongoose، سيفيدك الاطلاع على توثيقها لتعلّم كل اﻹمكانيات التي تقدمها للتطبيقات الفعلية. استخدم سطر اﻷوامر التالي لتثبيت Mongoose:

npm i mongoose

لنبدأ بإعداد خدمة Mongoose ﻹدارة الاتصال مع قاعدة البيانات MongoDB، وطالما أن عدة موارد تتشارك هذه الخدمة، سنضيفها إلى الملجد common مباشرة. نستخدم أيضًا الكائن mongooseOptions لتخصيص خيارات Mongoose التالية (على الرغم من أن استخدامه ليس إلزاميًا):

  • useNeewUrlPrser: إن لم يُضبط هذا الخيار على القيمة true ستعرض Mongoose تحذير بأن هذه الخاصية عرضة للإهمال في النسخ المستقبلية depreciation warning.
  • useUnified Topology: يوصي توثيق Mongoose بضبط قيمة هذا الخيار على true، وذلك لاستخدام محرك إدارة اتصال أحدث.
  • serverSelectionTimeoutMS: لغايات تصميمية تتعلق بتطبيقنا، وفي حال نسيت تشغيل MongoDB قبل Node.js، سيكون اختيارك مدة أقل من المدة الافتراضية 30 ثانية سبيلًا لعرض معلومات عن MongoDB مباشرةً بانتظار استجابة الواجهة الخلفية.
  • useFindAndModify: تلغي القيمة false لهذا الخيار عرض التحذير deprecation warning، وقد أشار توثيق Mongoose إلى التحذير الناتج عن هذا الخيار من بين جميع الخيارات اﻷخرى. إذ يدفع هذا الخيار Mongoose لاستخدام ميزات أصلية أحدث لقاعدة البيانات MongoDB بدلًا من كائنات Mongoose أقدم.

إليك الشيفرة النهائية للملف common/services/mongoose.service.ts بعد جمع الخيارات السابقة مع بعض اﻹعدادات المتعلقة بإعادة محاولة الاتصال:

import mongoose from 'mongoose';
import debug from 'debug';

const log: debug.IDebugger = debug('app:mongoose-service');

class MongooseService {
    private count = 0;
    private mongooseOptions = {
     useNewUrlParser: true,
     useUnifiedTopology: true,
     serverSelectionTimeoutMS: 5000,
     useFindAndModify: false,
    };

    constructor() {
     this.connectWithRetry();
    }

    getMongoose() {
     return mongoose;
    }

    connectWithRetry = () => {
     log('Attempting MongoDB connection (will retry if needed)');
     mongoose
     .connect('mongodb://localhost:27017/api-db', this.mongooseOptions)
     .then(() => {
     log('MongoDB is connected');
     })
     .catch((err) => {
     const retrySeconds = 5;
     log(
     `MongoDB connection unsuccessful (will retry #${++this
      .count} after ${retrySeconds} seconds):`,
     err
     );
     setTimeout(this.connectWithRetry, retrySeconds * 1000);
     });
    };
}
export default new MongooseService();

تأكد أنك تميّز بين الدالة ()connect التي توفرها مكتبة Mongoose وبين دالة الخدمة الخاصة بنا connectWithRetry:

  • تحاول mongoose.connect الاتصال بخدمة MongoDB الخاصة بنا (التي نشغلها باستخدام docker-compose) والذي ينتهي بعد زمن يُحدده الخيار serverSelectionTimeoutMS بالميلي ثانية.
  • تعيد الدالة connectWithRetry محاولة الاتصال السابقة في حال بدأ التطبيق العمل قبل عمل خدمة MongoDB. وطالما أنها دالة بانية لصنف متفرّد singleton، وبالتالي ستعمل هذه الدالة مرة واحدة، لكنها ستحاول استدعاء ()connect باستمرار يتخلل ذلك فترة توقف مدتها retrySeconds ثانية عند ينتهي الوقت المخصص لمحاولة الاتصال.

ستكون الخطوة التالية استبدال البيانات المقيمة في الذاكرة بقاعدة البيانات MongoDB!

إزالة قاعدة البيانات المقيمة في الذاكرة وإضافة MongoDB

استخدمنا سابقًا قاعدة البيانات المقيمة في الذاكرة كي نركّز في عملنا على بقية الوحدات البرمجية التي نبنيها. ولكي نستخدم MongoDB اﻵن، علينا أن نعيد بناء الملف users.dao.ts بالكامل، ويتطلب اﻷمر بداية إدراج اعتمادية أخرى:

import mongooseService from '../../common/services/mongoose.service';

نزيل اﻵن كل ما يتعلق بتعريف الصنف UserDao ما عدا الدالة البانية، ثم نعيد بناءه بإنشاء تخطيط Schema لبيانات المستخدم يعمل مع Mongoose قبل الدالة البانية:

Schema = mongooseService.getMongoose().Schema;

userSchema = new this.Schema({
    _id: String,
    email: String,
    password: { type: String, select: false },
    firstName: String,
    lastName: String,
    permissionFlags: Number,
}, { id: false });

User = mongooseService.getMongoose().model('Users', this.userSchema);

تعرّف الشيفرة السابقة مجموعة Mongoose الخاصة بنا، وتضيف ميزة خاصة لا تمتلكها قاعدة البيانات المقيمة في الذاكرة وهي الخيار select:false للحقل password لإخفاء هذا الحقل كلما طلبنا قائمة بأسماء المستخدمين.

قد يبدو تخطيط المستخدم مألوفًا فهو يشبه تخطيط كائن نقل البيانات DTO الذي تعاملنا معه، لكن الفرق الرئيسي هو أننا نعرّف الحقول التي يجب أن تتواجد ضمن مجموعة MongoDB التي تُدعى Users، بينما يُعرّف الكائن DTO الحقول المقبولة في طلبات HTTP. مع ذلك لن يتغير هذا الجزء المتعلق بكائن DTO، إذ سندرج كائنات DTO الثلاث التي عرّفناها سابقًا في أعلى الملف users.dao.ts. لكن وقبل كتابة شيفرة العمليات اﻷساسية CRUD على قاعدة البيانات، سنجري تغييرين على كائنات DTO.

التغير اﻷول على كائن DTO: الحقل id_ مقابل الحقل id

نزيل الحقل id من كائنات DT لأن Mongoose تقدم الحقل id_ تلقائيًا، إذ يأتي ضمن معاملات طلب الوجهة (المسار route). وانتبه إلى أن نماذج Mongoose تزوّدنا بمستخلص افتراضي للمعرّف id، لهذا عطلّنا هذا الخيار كالتالي:{ id: false } لتفادي أي التباس. ولأن هذا اﻷمر يعطّل مرجعنا إلى user.id ضمن الدالة الوسيطة ()validateSameEmailBelongToSameUser، سنتحتاج إلى user._id بدلًا عنه.

تستخدم بعض قواعد البيانات id وتستخدم أخرى id_ للإشارة إلى حقل المعرّف. لكن في تطبيقنا الذي يستخدم Mongoose، انتبهنا ببساطة إلى أي ترميز نستخدمه في كل مرحلة من مراحل كتابة الشيفرة، لكن مع ذلك سيظهر الاختلاف لمستثمري الواجهة البرمجية كما يوضح الشكل التالي:

01 id exposure

(تظهر الصورة أعلاه استخدام وظهور معرفات المستخدم في مشروع الواجهة البرمجية REST بصورته النهائية. ولاحظ كيف يختلف ترميز حقل المعرّف باختلاف مصدر المعرّف سواء كان معامل لطلب مباشر، أو بيانات مشفرة بطريقة JWT أو سجل قاعدة بيانات مُحضر حديثًا).

التغيير الثاني: إعداد السماحيات المبنية على تفعيل رايات محددة

سنغيّر أيضًا الراية permissionLevel إلى permissionFlags في كائنات DTO لنعكس عملنا على منظومة سماحيات أكثر تعقيدًا، إضافة إلى تعريف مخطط Mongoose السابق userSchema.

كائنات DTO ومبدأ عدم التكرار DRY

تذكّر أن ما يضمه كائن DTO هي فقط الحقول التي نريد تمريرها بين الواجهة البرمجية وقاعدة البيانات، وقد يبدو اﻷمر بأنه تداخل أو تكرار في البيانات الموجودة في نموذج التعامل مع البيانات وتلك الموجودة في كائنات DTO وانتهاكًا لمبدأ "عدم التكرار"، لكن لا تبالغ في تطبيق هذا المبدأ على حساب اﻷمان الذي يكون في أفضل حالاته مع اﻹعدادات الافتراضية. فعندما يتطلب اﻷمر مثلًا إضافة حقل في مكان واحد، من المحتمل عندها أن يعرضه المطوّرون في الواجهة البرمجية دون قصد وغير منتبهين إلى أن تخزين البيانات ونقلها سياقان منفصلان وقد يتطلب كلًا منهما مجموعات مختلفة من المتطلبات.

يمكننا بعد الانتهاء من التغييرات على كائنات DTO تنفيذ توابع العمليات اﻷساسية CRUD (بعد الدالة البانية UsersDao)، وسنبدأ بعملية إنشاء مستخدم جديد:

async addUser(userFields: CreateUserDto) {
    const userId = shortid.generate();
    const user = new this.User({
     _id: userId,
     ...userFields,
     permissionFlags: 1,
    });
    await user.save();
    return userId;
}

لاحظ أننا نلغي قيمة permissionFlags التي قد يرسلها مستثمر الواجهة البرمجية عبر userFields, ونستبدلها بالقيمة 1.

ننتقل تاليًا إلى عملية القراءة التي لها وظائف الحصول على مستخدم من خلال معرّفه أو من خلال البريد اﻹلكتروني أو الحصول على قائمة المستخدمين ضمن صفحات:

async getUserByEmail(email: string) {
    return this.User.findOne({ email: email }).exec();
}

async getUserById(userId: string) {
    return this.User.findOne({ _id: userId }).populate('User').exec();
}

async getUsers(limit = 25, page = 0) {
    return this.User.find()
     .limit(limit)
     .skip(limit * page)
     .exec();
}

ستكفي دالة DAO واحدة لتحديث مستخدم لأن دالة Mongoose في الخلفية findOneAndUpdate قادرة على تحديث المستند بأكمله أو تحديث جزء منه. وانتبه إلى أن دالتنا الخاصة تأخذ أحد القيمتين PatchUserDto أو PutUserDto للمعامل userFields من خلال استخدام نوع الاجتماع (|) في TypeScript:

async updateUserById(
    userId: string,
    userFields: PatchUserDto | PutUserDto
) {
    const existingUser = await this.User.findOneAndUpdate(
     { _id: userId },
     { $set: userFields },
     { new: true }
    ).exec();

    return existingUser;
}

يبلغ الخيار new: true مكتبة Mongoose بأن تعيد الكائن كما هو بعد التحديث بدلًا عما كانه أصلًا.

أما عملية الحذف فهي موجزة في Mongoose:

async removeUserById(userId: string) {
    return this.User.deleteOne({ _id: userId }).exec();
}

قد يلاحظ القارئ أن كل استدعاء للدوال اﻷعضاء للصنف User مقترن باستدعاء للدالة ()exec، وهذا اﻷمر اختياري لكنه مفضّل بين مطوري Mongoose لأنه يقدم طريقة أفضل في تتبع حالة المكدس عند التنقيح.

علينا اﻻن بعد الانتهاء من كتابة شيفرة كائن DAO تحديث الملف users.service.ts قليلًا حتى يتماشى مع الدوال الجديدة. ولا حاجة هنا ﻹعادة بناء الملف، بل فقط ثلاث لمسات بسيطة:

@@ -16,3 +16,3 @@ class UsersService implements CRUD {
     async list(limit: number, page: number) {
-     return UsersDao.getUsers();
+     return UsersDao.getUsers(limit, page);
     }
@@ -20,3 +20,3 @@ class UsersService implements CRUD {
     async patchById(id: string, resource: PatchUserDto): Promise<any> {
-     return UsersDao.patchUserById(id, resource);
+     return UsersDao.updateUserById(id, resource);
     }
@@ -24,3 +24,3 @@ class UsersService implements CRUD {
     async putById(id: string, resource: PutUserDto): Promise<any> {
-     return UsersDao.putUserById(id, resource);
+     return UsersDao.updateUserById(id, resource);
     }

يبقى استدعاء بقية الدوال كما هو تمامًا، لأننا أبقينا الهيكلية التي أنشأناها سابقًا على الرغم من إعادة بناء الصنف UsersDao مع بعض الاستثناءات:

  • نستخدم اﻵن الدالة ()updateUserById لتنفيذ العمليتين PUT و PATCH (والسبب كما ذكرنا سابقًا أننا ندعم النهج النمطي لتنفيذ واجهات برمجية REST، بعيدًا عن بعض التوصيات التي لا تدعم استخدام PUT في إنشاء موارد جديدة غير موجودة أصلًا كي لا تسمح الواجهة الخلفية لمستثمريها التحكم بتولد المعرّفات ID).
  • طالما أن كائن DAO الجديد يستخدم المعاملين limit و page، سنمررهما إلى الدالة ()getUsers.

تقدّم الهيكلية السابقة نموذجًا متماسكًا، إذ يرفض أية محاولة مثلًا لاستبدال MongoDB Mongoose بمكتبات أخرى مثل TypefORM و PostgreSL. ولتنفيذ مثل هذه التغييرات لا بد من إعادة بناء كل دالة من دوال DAO مع الحفاظ على بصمة هذه الدوال signature لتتفق مع بقية الشيفرة.

اختبار الواجهة البرمجية REST المبنية على Mongoose

لنشغّل الواجهة البرمجية باستخدام سطر اﻷوامر npm start، ونحاول إنشاء مستخدم:

curl --request POST 'localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "password":"secr3tPass!23",
    "email":"marcos.henrique@toptal.com"
}'

يتضمن كائن الاستجابة معرّف المستخدم الجديد:

{
    "id": "7WYQoVZ3E"
}

وسيكون إجراء بقية الاختبارات يديويًا أسهل باستخدام متغيرات البيئة:

REST_API_EXAMPLE_ID="put_your_id_here"

يبدو تحديث مستخدم كالتالي:

curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "Marcos",
    "lastName": "Silva"
}'

من المفترض ان تبدأ الاستجابة بالترويسة HTTP/1.1 204 No Content. (لاحظ أنه بدون include-- لن تُطبع أية استجابة). ولكي تتحقق من نجاح الطلبين السابقين نفّذ ما يلي:

curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "Marcos",
    "lastName": "Silva"
}'

تعرض الاستجابة الحقول المتوقعة ومن بينها id_ الذي ناقشناه سابقًا:

{
    "_id": "7WYQoVZ3E",
    "email": "marcos.henrique@toptal.com",
    "permissionFlags": 1,
    "__v": 0,
    "firstName": "Marcos",
    "lastName": "Silva"
}

لاحظ وجود حقل خاص هو v__  يُستخدم من قبل Mongoose لتحديد الإصدار وسيتغير في كل مرة يُحدَّث فيها نفس السجّل.

دعونا اﻵن نطلب قائمة بأسماء المستخدمين:

curl --request GET 'localhost:3000/users' \
--header 'Content-Type: application/json'

وستكون الاستجابة نفسها، لكنها مغلفة بالقوسين [].

بعد أن تأكدنا أن كلمة المرور قد حُفظت بأمان، سنتحقق من إمكانية حذف المستخدم:

curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json'

نتوقع أن نحصل هنا على الاستجابة 204 مجددًا.

قد تتسائل إن كان حقل كلمة المرور يعمل كما نتوقع، طالما أننا حددنا الخيار select: false في تعريف تخطيط Mongoose وبالتالي لن يظهر الحقل عند الاستجابة للطلب GET. لهذا سنكرر إنشاء مستخدم جديد ونتحرى اﻷمر (لا تنسى تخزين المعرّف الجديد لاستخدامات لاحقة).

كلمات المرور المخفية والتنقيح المباشر للبيانات في حاوية MongoDB

للتحقق من التخزين اﻵمن لكلمات المرور (مشفرّة بدلًا من أن تظهر كما هي)، يمكن للمطورين تفحّص MongoDB مباشرة. وأحد الطرق الممكنة استخدام واجهة سطر اﻷوامر المعيارية mongo من داخل حاوية Docker قيد التشغيل:

sudo docker exec -it toptal-rest-series_mongo_1 mongo

من هناك نفّذ اﻷمر use api-db يتبعه ()db.users.find().pretty لتحصل على بيانات جميع المستخدمين بما فيها كلمات المرور. أما لمن يفضل العمل على واجهات رسومية، بإمكانهم تثبيت عميل MongoDB مثل Robo 3T:

02 robo3t mongo client

تُعد البادئة $...argon2 جزءًا من التنسيق النصي PHC وقد خّزنت دون تعديل عن قصد. فإظهار عبارة Argon2 ومعاملاتها العامة لن يساعد المخترقين على تحديد كلمة النص السرياﻷصلية إن استطاعوا سرقة قاعدة البيانات. ويمكن تعزيز كلمة المرور أكثر باستخدام التغفيل salting، وهي تقنية سنستخدمها لاحقًا مع مفتاح JWT.

تأكدنا اﻵن أن Mongoose ترسل البيانات بنجاح إلى MongoDB، لكن كيف نتأكد أن مستثمري الواجهة البرمجية سيرسلون بيانات مناسبة ضمن طلباتهم إلى الوجهات الخاصة المستخدم؟

إضافة المكتبة express-validator

هنالك طرق عدة للتحقق من الطلبات، سنستخدم منها في تطبيقنا المكتبة express-validator وهي مكتبة مستقرة وسهلة الاستخدام وتتمتع بتوثيق جيد. وعلى الرغم من إمكانية استخدام وظيفة التحقق المضمنة مع Mongoose، لكن الميزات التي Express.js يقدمها express-validator أكثر. إذ تقدم لك المكتبة متحققًا جاهزًا من صحة البريد اﻹلكتروني، بينما عليك كتابة شيفرته بنفسك في Mongoose.

سنثبت اﻵن المكتبة express-validator:

npm i express-validator

ولكي نحدد الحقول التي نتحقق من صحتها، نستخدم التابع ()body الذي سندرجه في الملف users.routes.config.ts. يتحقق بعدها التابع ()bodyمن الحقول ويولّد قائمة باﻷخطاء مخزّنة في الكائن express.Request في حال أخفق الطلب، ونحتاج عندها إلى أداة وسيطة كي تستفيد من تلك القائمة وتتحقق من اﻷخطاء.

وطالما أننا نستخدم نفس المنطق على بقية المسارات، سننشئ الملف common/middleware/body.validation.middleware.ts الذي يضم الشيفرة التالية:

import express from 'express';
import { validationResult } from 'express-validator';

class BodyValidationMiddleware {
    verifyBodyFieldsErrors(
     req: express.Request,
     res: express.Response,
     next: express.NextFunction
    ) {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
     return res.status(400).send({ errors: errors.array() });
     }
     next();
    }
}
export default new BodyValidationMiddleware();

وهكذا سنكون قادرين على التعامل مع أية أخطاء تولّدها الدالة ()body. لنضف اﻵن ما يلي إلى الملف users.routes.config.ts:

import BodyValidationMiddleware from '../common/middleware/body.validation.middleware';
import { body } from 'express-validator';

نستطيع اﻵن تحديث وجهاتنا كالتالي:

@@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig {
     .post(
-     UsersMiddleware.validateRequiredUserBodyFields,
+     body('email').isEmail(),
+     body('password')
+     .isLength({ min: 5 })
+     .withMessage('Must include password (5+ characters)'),
+     BodyValidationMiddleware.verifyBodyFieldsErrors,
     UsersMiddleware.validateSameEmailDoesntExist,
@@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig {
     this.app.put(`/users/:userId`, [
-     UsersMiddleware.validateRequiredUserBodyFields,
+     body('email').isEmail(),
+     body('password')
+     .isLength({ min: 5 })
+     .withMessage('Must include password (5+ characters)'),
+     body('firstName').isString(),
+     body('lastName').isString(),
+     body('permissionFlags').isInt(),
+     BodyValidationMiddleware.verifyBodyFieldsErrors,
     UsersMiddleware.validateSameEmailBelongToSameUser,
@@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig {
     this.app.patch(`/users/:userId`, [
+     body('email').isEmail().optional(),
+     body('password')
+     .isLength({ min: 5 })
+     .withMessage('Password must be 5+ characters')
+     .optional(),
+     body('firstName').isString().optional(),
+     body('lastName').isString().optional(),
+     body('permissionFlags').isInt().optional(),
+     BodyValidationMiddleware.verifyBodyFieldsErrors,
     UsersMiddleware.validatePatchEmail,

لا تنس إضافة BodyValidationMiddleware.verifyBodyFieldsErrors في كل وجهة بعد أي سطر يضم الدالة ()body وإلا لن يكون لها فائدة.

لاحظ أيضًا كيف حدّثنا الوجهتين PUT و POST لاستخدام express-validater بدلًا من الدالة validateRequiredUserBodyFields التي بنيناها بأنفسنا. لأنهما الوجهتان الوحيدتان اللتان تستخدمان تلك الدالة. ويمكن حذف شيفرتها من الملف users.middleware.ts.

هذا كل ما في اﻷمر، شغّل Node.js وجرّب باستخدام أي عميل REST تفضلّه كيف تتعامل الواجهة البرمجية مع المدخلات المختلفة. ولا تنس اﻹطلاع على توثيق express-validator لترى اﻹمكانيات اﻷخرى التي تقدّمها. فما فعلنا هو مجرد نقطة انطلاق.

الاستيثاق والسماحيات (منح التصريحات)

تتيح واجهتنا البرمجية مجموعة من نقاط الوصول endpoints لمستثمري الواجهة كي ينشئوا ويعدّلوا المستخدمين ويطلبو قوائم بهم، وتمنح كل نقطة منها وصولًا عامًا غير محدود. وتمنع أنماط الاستخدام أي مستخدم من تغيير بيانات آخر، كما تمنع أي دخلاء من الوصول إلى أية نقطة لا نريد وصولًا عامًا إليها.

تتضمن القيود السابقة ناحيتين أساسيتين نختصرهما بالعبارة "auth" وتعني الإذن: اﻷولى هي الاستيثاق authentication وتعني معرفة صاحب الطلب، والثانية هي الترخيص أو التصريح authorization أي السماح لصاحب الطلب بتنفيذ طلبه أو لا. لهذا انتبه جيدًا إلى الناحية التي تُناقش، وخاصة في طلبات HTTP المعيارية، فالحالة 401 Unauthorized تتعلق بالاستيثاق، أما 403 Forbidden فتتعلق بالتصريح ولهذا السبب سنستخدم الاصطلاح "auth" ليعني الاستيثاق، ونستخدم المصطلح سماحيات permissions لمواضيع التصاريح.

هنالك العديد من نُهج الاستيثاق التي يمكنك الاطلاع عليها، بما في ذلك الخدمات التي يقدمها طرف ثالث مثل Ath0، لكننا سنستخدم في تطبيقنا نهجًا أبسط مبني على مفاتيح JWT لكنه قابل للتوسع.

يتكون مفتاح JWT من نص JSON مشفّر مع بعض البيانات الوصفية التي لا تتعلق بالاستيثاق، وتضم في حالتنا البريد اﻹلكتروني للمستخدم ورايات السماحيات permissions flags. كما يضم نص JSON نصًا سرًيا Secret للتحقق من سلامة البيانات الوصفية.

إن الفكرة التي نتبعها هي الطلب من العميل إرسال JWT ضمن كل طلب غير عام، مما يسمح لنا بالتحقق من امتلاك العميل الثبوتيات الصحيحة لاستخدام نقطة الوصول المطلوبة دون الحاجة إلى إرسال الثبوتيات بأنفسهم عبر قناة الاتصال عند كل طلب.

لكن في أي مكان سنكتب الشيفرة المناسبة؟ يمكن من خلال الدوال الوسيطة استخدامها عندما تكون ضمن إعدادات الوجهات (المسارات).

إضافة وحدة الاستيثاق

لنحضّر أولًا ما يضمه مفتاح JWT، إذ سنبدأ استعمال الحقل permissionFlags العائد إلى مورد المستخدم لأنه فقط يقدم بيانات وصفية ملائمة للتشفير ضمن مفاتيح JWT، وليس لوجود أي علاقة بين مفاتيح JWT ومنطق تحديد السماحيات.

سنضيف قبل إنشاء أداة وسيطة middleware لتوليد مفاتيح JWT دالة خاصة إلى الملف users.dao.ts تستخلص حقل كلمة المرور، لأننا أعددنا Mongoose لتمنع عرضه:

async getUserByEmailWithPassword(email: string) {
    return this.User.findOne({ email: email })
     .select('_id email permissionFlags +password')
     .exec();
}

أضف ما يلي في الملف users.dao.ts:

async getUserByEmailWithPassword(email: string) {
    return UsersDao.getUserByEmailWithPassword(email);
}

ننشئ اﻵن المجلد auth في جذر المشروع، ونضيف نقطة وصول لنسمح لمستثمري الواجهة البرمجية بتوليد مفاتيح JWT. لهذا ننشئ أولًا الوحدة الوسيطة
auth/middleware/auth.middleware.ts على شكل صنف متفرّد singleton يُدعى AuthMiddleware، كما سنحتاج إلى استيراد بعض المكتبات:

import express from 'express';
import usersService from '../../users/services/users.service';
import * as argon2 from 'argon2';

ننشئ ضمن الصنف AuthMiddleware دالة تتحقق من وجود ثبوتيات صالحة ضمن طلبات مستخدمي الواجهة البرمجية:

async verifyUserPassword(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    const user: any = await usersService.getUserByEmailWithPassword(
     req.body.email
    );
    if (user) {
     const passwordHash = user.password;
     if (await argon2.verify(passwordHash, req.body.password)) {
     req.body = {
     userId: user._id,
     email: user.email,
     permissionFlags: user.permissionFlags,
     };
     return next();
     }
    }
    // إعطاء نفس الرسالة في كلتا الحالتين
    // يساعد في الحماية من محاولات الاختراق
    res.status(400).send({ errors: ['Invalid email and/or password'] });
}

ولكي تتأكد الدالة الوسيطة من وجود الحقلين email و password في جسم الطلب req.body، نستخدم express-validator، عندما نهيئ لاحقًا المسار لاستخدام الدالة ()verifyUserPassword.

تخزين النص السري ضمن مفتاح JWT

نحتاج إلى سر كي نوّلد مفتاح JWT، الذي نستخدمه في تعليم المفتاح وللتحقق من صحة المفاتيح القادمة مع طلبات العملاء. سنخزّن هذا النص السريضمن ملف متغيّر بيئة env. منفصل بدلًا من كتابته ضمن ملف TypeScript، ولن يُدفع هذا الملف إلى مستودع الشيفرة. وكما جرت العادة، أضفنا الملف env.example. إلى المستودع لمساعدة المطورين على فهم أي المتغيرات مطلوبة عند إنشاء الملف env. الحقيقي. ونحتاج في حالتنا إلى متغير يُدعى JWT_SECRET يُخزّن سر مفتاح JWT على هيئة نص. وتذكّر أن تغيّر هذا النص السريفي نسختك إن أردت العمل على التطبيق انطلاقًا من المستودع الذي يضم المشروع المكتمل في نهاية هذه السلسلة من المقالات. كما تجدر اﻹشارة هنا إلى ضرورة اتباع الممارسات اﻷفضل عند استخدام مفاتيح JWT بالتمييز بين المفاتيح وفق البيئة (تطوير، إنتاج،…).

ينبغي على الملف env. الموجود في جذر المشروع اتباع التنسيق التالي، لكن ليس بالضرورة استخدام نفس السر:

JWT_SECRET=My!@!Se3cr8tH4sh3

ومن الطرق السهلة لتحميل هذه المتغيرات ضمن التطبيق استخدام مكتبة تُدعى dotenv:

npm i dotenv

وكل ما تحتاجه لتهيئتها هو استدعاء الدالة ()dotenv.config حالما تشغّل التطبيق. لهذا أضف الشيفرة التالية أعلى الملف app.ts:

import dotenv from 'dotenv';
const dotenvResult = dotenv.config();
if (dotenvResult.error) {
    throw dotenvResult.error;
}

متحكم الاستيثاق

آخر المتطلبات التي تلزمنا قبل توليد مفتاح JWT هو تثبيت المكتبة jsonwebtoken وأنواع TypeScript الخاصة بها:

npm i jsonwebtoken
npm i --save-dev @types/jsonwebtoken

لننشئ اﻵن المتحكم auth/controllers/auth.controller.ts، ولا حاجة ﻹدراج المكتبة dotenv لأن إدراجها في ملف التطبيق app.ts جعل محتوى ملف متغيرات البيئة متاحًا ضمن كامل التطبيق من خلال كائن Node.js العام process:

import express from 'express';
import debug from 'debug';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const log: debug.IDebugger = debug('app:auth-controller');

/**
* تُنشر هذه القيمة تلقائيًا من ملف متغيرات البيئة الذي عليك إنشاءه بنفسك في جذر المشروع
*في المستودع لمعرفة التنسيق المطلوب .env.example اطلع على الملف
*/
// @ts-expect-error
const jwtSecret: string = process.env.JWT_SECRET;
const tokenExpirationInSeconds = 36000;

class AuthController {
    async createJWT(req: express.Request, res: express.Response) {
     try {
     const refreshId = req.body.userId + jwtSecret;
     const salt = crypto.createSecretKey(crypto.randomBytes(16));
     const hash = crypto
     .createHmac('sha512', salt)
     .update(refreshId)
     .digest('base64');
     req.body.refreshKey = salt.export();
     const token = jwt.sign(req.body, jwtSecret, {
     expiresIn: tokenExpirationInSeconds,
     });
     return res
     .status(201)
     .send({ accessToken: token, refreshToken: hash });
     } catch (err) {
     log('createJWT error: %O', err);
     return res.status(500).send();
     }
    }
}

export default new AuthController();

تربط المكتبة المفتاح الجديد بالنص السري الذي حددناه jwtSecret. كما سنوّلد مُوهِم (أو مُغفِّل salt) ومعمّي hash باستخدام الوحدة البرمجية اﻷصلية crypto في Node.js، وبعدها ننشئ المفتاح الذي يتمكن مستثمرو الواجهة من تحديث مفتاح JWT الحالي، وهذا اﻷمر مفيد بشكل خاص عند توسيع التطبيق.

لكن ما الفرق بين refreshKey و refreshToken و accessToken؟ تُرسل هذه المفاتيح إلى مستثمر الواجهة البرمجية والغاية من ذلك هو: استخدام accessToken للطلبات التي تقع خارج إطار الوصول العام، و refreshToken لطلب استبدال مفتاح accessToken منتهي الصلاحية. ويستخدم المفتاح refreshKey لتمرير المتغير salt مشفّرًا ضمن المفتاح refreshToken إلى دالة التحديث الوسيطة التي سنشرحها لاحقًا.

تجدر الملاحظة إلى أننا اعتمدنا على المكتبة jsonwebtoken في تحديد مدة صلاحية المفتاح، فإن انتهت صلاحية مفتاح لا بد من إعادة الاستيثاق من جديد:

مسار التحقق اﻷساسية في الواجهة البرمجية REST المبنية على Node.js

لنهيئ نقطة الوصول ضمن الملف auth/auth.routes.config.ts:

import { CommonRoutesConfig } from '../common/common.routes.config';
import authController from './controllers/auth.controller';
import authMiddleware from './middleware/auth.middleware';
import express from 'express';
import BodyValidationMiddleware from '../common/middleware/body.validation.middleware';
import { body } from 'express-validator';

export class AuthRoutes extends CommonRoutesConfig {
    constructor(app: express.Application) {
     super(app, 'AuthRoutes');
    }

    configureRoutes(): express.Application {
     this.app.post(`/auth`, [
     body('email').isEmail(),
     body('password').isString(),
     BodyValidationMiddleware.verifyBodyFieldsErrors,
     authMiddleware.verifyUserPassword,
     authController.createJWT,
     ]);
     return this.app;
    }
}

لا تنس إضافة التالي إلى الملف app.ts:

// ...
import { AuthRoutes } from './auth/auth.routes.config';
// ...
routes.push(new AuthRoutes(app)); //Userroutes قد يكون قبل أو بعد
// ...

أصبحنا اﻵن مستعدين ﻹعادة تشغيل Node.js واختبار الشيفرة، ولا بد من استخدام نفس الثبوتيات التي استخدمناها عند إنشاء المستخدم سابقًا:

curl --request POST 'localhost:3000/auth' \
--header 'Content-Type: application/json' \
--data-raw '{
    "password":"secr3tPass!23",
    "email":"marcos.henrique@toptal.com"
}'

ستكون الاستجابة شبيهة بالتالي:

{
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8",
    "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ=="
}

وكما فعلنا سابقًا، سنستخدم متغيرات البيئة لاستخدام القيم السابقة بشكل ملائم:

REST_API_EXAMPLE_ACCESS="put_your_access_token_here"
REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"

وهكذا نكون قد حصلنا على مفتاح الوصول ومفتاح التحديث لكننا نحتاج إلى أداة وسيطة للاستفادة منهما.

وحدة وسيطة للتعامل مع JWT

نحتاج بداية إلى نوع TypeScript جديد للتعامل مع بنية المفتاح JWT بشكلها غير المرمّز، لهذا أنشئ الملف الذي يضم الشيفرة التالية common/types/jwt.ts:

export type Jwt = {
    refreshKey: string;
    userId: string;
    permissionFlags: string;
};

لنكتب اﻵن شيفرة الدوال الوسيطة التي تتحقق من وجود مفتاح تحديث، والتحقق منه، والتحقق من مفتاح JWT. وسنضع الدوال الثلاث في الملف الجديد common/types/jwt.ts:

import express from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { Jwt } from '../../common/types/jwt';
import usersService from '../../users/services/users.service';

// @ts-expect-error
const jwtSecret: string = process.env.JWT_SECRET;

class JwtMiddleware {
    verifyRefreshBodyField(
     req: express.Request,
     res: express.Response,
     next: express.NextFunction
    ) {
     if (req.body && req.body.refreshToken) {
     return next();
     } else {
     return res
     .status(400)
     .send({ errors: ['Missing required field: refreshToken'] });
     }
    }

    async validRefreshNeeded(
     req: express.Request,
     res: express.Response,
     next: express.NextFunction
    ) {
     const user: any = await usersService.getUserByEmailWithPassword(
     res.locals.jwt.email
     );
     const salt = crypto.createSecretKey(
     Buffer.from(res.locals.jwt.refreshKey.data)
     );
     const hash = crypto
     .createHmac('sha512', salt)
     .update(res.locals.jwt.userId + jwtSecret)
     .digest('base64');
     if (hash === req.body.refreshToken) {
     req.body = {
     userId: user._id,
     email: user.email,
     permissionFlags: user.permissionFlags,
     };
     return next();
     } else {
     return res.status(400).send({ errors: ['Invalid refresh token'] });
     }
    }

    validJWTNeeded(
     req: express.Request,
     res: express.Response,
     next: express.NextFunction
    ) {
     if (req.headers['authorization']) {
     try {
     const authorization = req.headers['authorization'].split(' ');
     if (authorization[0] !== 'Bearer') {
     return res.status(401).send();
     } else {
     res.locals.jwt = jwt.verify(
      authorization[1],
      jwtSecret
     ) as Jwt;
     next();
     }
     } catch (err) {
     return res.status(403).send();
     }
     } else {
     return res.status(401).send();
     }
    }
}

export default new JwtMiddleware();

تتحقق الدالة ()validRefreshNeeded أيضًا إذا ما كان مفتاح التحديث صحيحًا من أجل معرّف مستخدم محدد. فإن كان كذلك، سنستخدم الدالة authController.createJWT لتوليد مفتاح JWT جديد للمستخدم.

وتتحقق الدالة ()validJWTNeeded, إن أرسل مستثمر الواجهة البرمجية مفتاح JWT صالح ضمن ترويسة طلب HTTP وفق الصيغة Authorization: Bearer <token> (وهذا أيضًا للأسف تضارب آخر بين الاستيثاق والتصريح).

علينا اﻵن تهيئة مسار جديد لتحديث المفتاح، وقد شُفِّرت ضمنه رايات السماحية.

مسار لتحديث مفاتيح JWT

سندرج اﻷداة الوسطية الجديدة ضمن الملف auth.routes.config.ts:

import jwtMiddleware from './middleware/jwt.middleware';

ثم سنضيف المسارات التالية:

this.app.post(`/auth/refresh-token`, [
    jwtMiddleware.validJWTNeeded,
    jwtMiddleware.verifyRefreshBodyField,
    jwtMiddleware.validRefreshNeeded,
    authController.createJWT,
]);

سنختبر الآن إن كانت تعمل كما هو مطلوب باستخدام المفتاحين accessToken و refreshToken اللذان حصلنا عليهما سابقًا:

curl --request POST 'localhost:3000/auth/refresh-token' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \
--data-raw "{
    \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\"
}"

علينا أن نتوقع الحصول على مفتاحين جديدين accessToken و refreshToken كي نستخدمهما لاحقًا. بإمكانك اﻵن تجريب كيف تتحقق الواجهة الخلفية من المفتاحين السابقين، وكيف تحدد عدد المرات التي يمكنك فيها الحصول على مفاتيح جديدة.

واﻵن سيتمكن مستثمرو الواجهة الخلفية من إنشاء مفاتيح JWT والتحقق منها وتحديثها.

الخلاصة

أكملنا في هذا المقال العمل على تطبيقنا الذي يُنشئ واجهة برمجة REST باستخدام TypeScript و Express.js في بيئة Node.js وقد انتقلنا في هذا القسم من قاعدة بيانات مؤقتة مقيمة في الذاكرة إلى استخدام قاعدة بيانات MongoDB بالاستعانة بالمكتبة Mongoose كما عملنا على تنفيذ آلية للتحقق من صحة المدخلات باستخدام مفاتيح ويب JWT والمكتبة jsonwebtoken. أما عن مفاهيم السماحيات وكيفية تنفيذها لتتكامل مع مفاتيح JWT فهذا ما سنراه في المقال التالي.

ترجمة -وبتصرف- للقسم اﻷول من المقال Building a Node.js TypeScript REST API, Part 3 MongoDB, Authentication, and Automated Tests

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...