full_stack_101 الاستيثاق عبر مفتاح المستخدم المشفر (token authentication) في تطبيق Node.js و React


ابراهيم الخضور

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

يمكن تقديم أساسيات التحقق المبني على الاستيثاق من خلال المخطط التتابعي التالي:

token_auth_diagram_01.png

  • يسجل المستخدم دخوله أولًا باستخدام نافذة تسجيل الدخول التي أضيفت إلى تطبيق React (سنفعل ذلك في القسم 5).
    • سيدفع تسجيل الدخول شيفرة React إلى إرسال اسم المستخدم وكلمة المرور إلى العنوان api/login/ على الخادم عن طريق طلب HTTP-POST.
  • إن كان اسم المستخدم وكلمة المرور صحيحين، سيوّلد الخادم مفتاح مشفَّر (token) يكون بمثابة شهادة تعرّف بطريقة ما المستخدم الذي سجل دخوله.
    • يتميز المتفاح بأنه معلَّم بعلامة رقمية (يقال عليه توقيع رقمي أحيانًا) مما يجعل تزويره مستحيلًا باستخدام طرق التزوير المعروفة.
  • يستجيب الخادم برمز حالة يشير إلى نجاح العملية ويعيد نسخة مفتاح مشفر مع الاستجابة.
  • يحفظ المتصفح بهذا المفتاح في حالة تطبيق React مثلًا.
  • عندما ينشئ المستخدم ملاحظة جديدة (أو عندما يقوم بأي أمر يتطلب التوثيق)، سترسل React نسخة من المفتاح مع الطلب إلى الخادم.
  • يستخدم عندها الخادم هذا المفتاح للتحقق من المستخدم (العملية مشابهة تقريبًا لفتح باب المنزل بالمفتاح الخاص بصاحب المنزل).

ملاحظة: سنطلق على هذا المفتاح المشفر (token) اسم «شهادة» مجازًا لأنه يشهد للمستخدم الحامل له بأنه صاحب الحساب الحقيقي المخول للوصول إلى حسابه وبياناته على الموقع.

لنضف أولًا وظيفة تسجيل الدخول. علينا تثبيت المكتبة jsonwebtoken التي تسمح لنا بتوليد شهادات ويب JSON (أي JSON web tokens وتختصر إلى JWT).

npm install jsonwebtoken --save

نضع شيفرة تسجيل الدخول التالية في الملف login.js ضمن المجلد controllers:

const jwt = require('jsonwebtoken')
const bcrypt = require('bcrypt')
const loginRouter = require('express').Router()
const User = require('../models/user')

loginRouter.post('/', async (request, response) => {
  const body = request.body

  const user = await User.findOne({ username: body.username })
  const passwordCorrect = user === null
    ? false
    : await bcrypt.compare(body.password, user.passwordHash)

  if (!(user && passwordCorrect)) {
    return response.status(401).json({
      error: 'invalid username or password'
    })
  }

  const userForToken = {
    username: user.username,
    id: user._id,
  }

  const token = jwt.sign(userForToken, process.env.SECRET)

  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

module.exports = loginRouter

يبدأ عمل الشيفرة بالبحث عن المستخدم ضمن قاعدة البيانات من خلال اسم المستخدم المرفق مع الطلب. بعد ذلك يتحقق من كلمة المرور التي ترفق أيضًا مع الطلب. وبما أن كلمات المرور لا تخزن كما هي في قواعد البيانات بل على شكل رموز محسوبة اعتمادًا عليها، سيتحقق التابع bcrypt.compare من صحة كلمة المرور:

await bcrypt.compare(body.password, user.passwordHash)

إن لم يُعثَر على المستخدم، أو كانت كلمة المرور خاطئة، سيسيب الخادم للطلب برمز الحالة 401 (غير مخول بالعملية) وسيحدد سبب إخفاق الطلب في جسم الاستجابة.

إن كانت كلمة المرور صحيحة، ستنشئ الشهادة باستخدام التابع jwt.sign. تحتوي الشهادة على اسم المستخدم ومعرف المستخدم بصيغة موقعة رقميًا.

const userForToken = {
  username: user.username,
  id: user._id,
}

const token = jwt.sign(userForToken, process.env.SECRET)

توّقع الشهادة رقميًا باستعمال قيمة نصية تمثل ""السر"" موجودة في متغير البيئة SECRET. يضمن هذا التوقيع عدم قدرة أي فريق لا يعرف كلمة "السر" من توليد شهادة صالحة. يجب أن توضع قيمة متغير البيئة في الملف ذو اللاحقة (env.).

يستجيب الخادم لأي طلب صحيح بإرسال رمز الحالة 200 (مناسب). تعاد بعدها الشهادة الناتجة واسم المستخدم ضمن جسم الاستجابة.

نضيف شيفرة تسجيل الدخول إلى التطبيق بإضافة متحكم المسار التالي إلى الملف app.js:

const loginRouter = require('./controllers/login')

//...

app.use('/api/login', loginRouter)

لنحاول تسجيل الدخول باستخدام VS Code REST-client:

vs_code_login_02.png

لن تنجح العملية وستطبع الرسالة التالية على الطرفية:

(node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value
    at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20)
    at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21)
(node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2)

أخفق الأمر (jwt.sign(userForToken, process.env.SECRET لأننا لم نضع قيمة "للسر" في متغير البيئة SECRET. سيعمل التطبيق بمجرد أن نقوم بذلك، وسيعيد تسجيل الدخول الناجح تفاصيل المستخدم والشهادة.

login_successful_03.png

سيولد اسم المستخدم الخاطئ أو كلمة المرور الخاطئة رسالة خطأ وسيعيد الخادم رمز الحالة المناسب.

login_failed_04.png

حصر تدوين الملاحظات بالمستخدمين المسجلين

لنمنع إنشاء ملاحظات جديدة ما لم تكن الشهادة المرفقة مع الطلب صحيحة. بعدها ستحفظ الملاحظة في قائمة ملاحظات المستخدم الذي تعرّفه الشهادة.

هناك طرق عديدة لإرسال الشهادة من المتصفح إلى الخادم. سنستخدم منها ترويسة التصريح (ِAuthorization). حيث توضح هذه الترويسة تخطيط الاستيثاق المستخدم. سيكون ذلك ضروريًا إن سمح الخادم بطرق متعددة للتحقق. حيث يوضح التخطيط للخادم الطريقة التي يفسر بها الشهادة المرفقة بالطلب. سيكون التخطيط Bearer مناسبًا لنا. وعمليًا لو كانت قيمة الشهادة على سبيل المثال (eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW) ستكون قيمة ترويسة التصريح:

Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW

ستتغير شيفرة إنشاء ملاحظة جديدة على النحو التالي:

const jwt = require('jsonwebtoken')
// ...
const getTokenFrom = request => {  
    const authorization = request.get('authorization')  
    if (authorization && authorization.toLowerCase().startsWith('bearer ')) 
    {    
        return authorization.substring(7)  
    }  
    return null}
notesRouter.post('/', async (request, response) => {
  const body = request.body
  const token = getTokenFrom(request)  
  const decodedToken = jwt.verify(token, process.env.SECRET)  
  if (!token || !decodedToken.id) 
  {    
      return response.status(401).json({ error: 'token missing or invalid' })  
  }  
    const user = await User.findById(decodedToken.id)
    const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    date: new Date(),
    user: user._id
  })

  const savedNote = await note.save()
  user.notes = user.notes.concat(savedNote._id)
  await user.save()

  response.json(savedNote)
})

تعزل الدالة المساعدة getTokenFrom الشهادة عن ترويسة التصريح، ويتحقق التابع jwt.verify من صلاحيتها. يفك التابع تشفيرالشهادة أيضًا أو يعيد الكائن الذي بُنيت على أساسه الشهادة.

const decodedToken = jwt.verify(token, process.env.SECRET)

يحمل الكائن الناتج عن الشهادة بعد فك شيفرته حقلي اسم المستخدم والمعرف الخاص به، ليخبر الخادم من أرسل الطلب.

إن لم تكن هناك شهادة، أو لم يحمل الكائن الناتج عن الشهادة معرف المستخدم، سيعيد الخادم رمز الحالة 401 (غير مصرّح له)، وسيحدد سبب الخطأ في جسم الاستجابة.

if (!token || !decodedToken.id) {
  return response.status(401).json({
    error: 'token missing or invalid'
  })
}

حالما يتم التحقق من هوية المستخدم، تتابع العملية التنفيذ بالشكل السابق.

يمكن إنشاء ملاحظة جديدة باستخدام Postman إن أعطت ترويسة التصريح القيمة النصية الصحيحة وهي bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ، وستكون القيمة الأخرى هي الشهادة التي تعيدها عملية تسجيل الدخول.

ستبدو العملية باستخدام Postman كالتالي:

new_note_postman_05.png

وباستعمال Visual Studio Code REST client كالتالي:

new_note_VS_06.png

معالجة الأخطاء

يمكن لعمليات التحقق أن تسبب أخطاء من النوع JsonWebTokenError. فلو أزلنا بعض المحارف من الشهادة وحاولنا كتابة ملاحظة جديدة، هذا ما سيحدث:

JsonWebTokenError: invalid signature
    at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19
    at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14)
    at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10)
    at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30)

هنالك أسباب عديدة لأخطاء فك التشفير. فقد تكون الشهادة خاطئة أو مزورة أو منتهية الصلاحية. إذًا سنوسع الأداة الوسطية لمعالجة الأخطاء لتأخذ بالحسبان أخطاء فك التشفير الممكنة:

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

const errorHandler = (error, request, response, next) => {
  if (error.name === 'CastError') {
    return response.status(400).send({
      error: 'malformatted id'
    })
  } else if (error.name === 'ValidationError') {
    return response.status(400).json({
      error: error.message 
    })
  } else if (error.name === 'JsonWebTokenError') {    return response.status(401).json({      
error: 'invalid token'    
})  
}

  logger.error(error.message)

  next(error)
}

يمكنك إيجاد شيفرة التطبيق في الفرع part4-9 ضمن المستودع المخصص للتطبيق على GitHub.

إن احتوى التطبيق على عدة واجهات تتطلب التحقق، يجب أن نضع أدوات التحقق JWT ضمن أداة وسطية مستقلة. ستساعدنا بعض المكتبات الموجودة مثل express-jwt في تحقيق ذلك.

ملاحظة ختامية

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

يجب استخدام كلمات السر وأسماء المستخدمين وشهادات التحقق مع البروتوكول HTTPS. يمكننا استخدام HTTPS ضمن Node في تطبيقنا بدلًا من HTTP، لكن ذلك سيتطلب أوامر تهيئة أكثر. من ناحية أخرى، تتواجد نسخة الإنتاج من تطبيقنا على Heroku، وبذلك سيبقى التطبيق آمنًا. فكل المسارات بين المتصفح وخادم Heroku تستخدم HTTPS.

سنضيف آلية تسجيل الدخول إلى الواجهة الأمامية في القسم التالي.

التمارين 4.15 - 4.22

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

تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما.

4.15 التوسع في قائمة المدونات: الخطوة 3

أضف طريقة لتسجيل مستخدم جديد باستخدام طلب HTTP إلى العنوان api/users/. للمستخدمين اسم حقيقي واسم للدخول وكلمة مرور. لا تخزّن كلمة السر كنص واضح، بل استخدم المكتبة bcrypt بالطريقة التي استخدمناها في القسم4 -فصل (إدارة المستخدمين).

ملاحظة: عانى بعض مستخدمي Windows أثناء العمل مع bcrypt. إن واجهتك المشاكل معها، ألغ تثبيتها كالتالي:

npm uninstall bcrypt --save 

ثم ثبت المكتبة bcryptjs بدلًا عنها.

أضف طريقة لعرض تفاصيل جميع المستخدمين من خلال طلب HTTP مناسب.

يمكن أن تظهر قائمة المستخدمين بالشكل التالي:

blog_all_users_07.png

4.16 التوسع في قائمة المدونات: الخطوة 4 *

أضف ميزة للتطبيق تنفذ التقييدات التالية:

  • اسم المستخدم وكلمة المرور إلزاميتان
  • يجب أن يكون طول كل من كلمة المرور واسم المستخدم ثلاثة محارف على الأقل.
  • يجب أن يكون اسم المستخدم فريدًا (غير مكرر).

يجب أن تعيد العملية رمز الحالة المناسب مع رسالة خطأ عند محاولة إنشاء مستخدم جديد مخالف للتقييدات.

ملاحظة: لا تختبر تقييد كلمة المرور باستعمال مقيمات Mongoose. لأن كلمة المرور التي تتلقاها الواجهة الخلفية وكلمة السر المرمزة التي تحفظ في قاعدة البيانات أمران مختلفان. يجب أن يقيم طول كلمة المرور باستخدام المتحكم كما فعلنا في القسم 3 قبل أن نستعمل مقيّمات Mongoose.

أضف اختبارًا يتأكد من عدم إضافة مستخدم إذا كانت بياناته غير صحيحة. يجب أن تعيد العملية رمز حالة مناسب ورسالة خطأ.

4.17 التوسع في قائمة المدونات: الخطوة 5

وسٍّع المدونات بحيث تحتوي كل مدونة على معلومات عن المستخدم الذي أنشأها. عدل في أسلوب إضافة مدونة جديدة بحيث يُحدد أحد المستخدمين الموجودين في قاعدة البيانات كمنشئ لها (قد يكون أول شخص يظهر مثلًا). نفذ المهمة اعتمادًا على معلومات الفصل الثالث- القسم 4. لا يهم حاليًا إلى أي مستخدم قد نسبت المدونة، لأننا سنهتم بذلك في التمرين 4.19.

عدّل طريقة عرض المدونات بحيث تظهر معلومات منشئ المدونة:

blog_show_creato08.png.png

عدل طريقة عرض قائمة المستخدمين بحيث تظهر المدونات التي أنشأها المستخدم:

users_show_blog_09.png.png

4.18 التوسع في قائمة المدونات: الخطوة 6

أضف أسلوب توثيق يعتمد على الشهادات كما فعلنا في بداية هذا الفصل.

4.19 التوسع في قائمة المدونات: الخطوة 7

عدّل في طريقة إضافة مدونة جديدة لتُنفَّذ فقط في الحالة التي تُرسل فيها شهادة صحيحة عبر طلب HTTP-POST.

ويعتبر عندها الشخص الذي تحدده الشهادة منشئ المدونة.

4.20 التوسع في قائمة المدونات: الخطوة 8 *

راجع المثال الذي أوردناه في هذا الفصل عن استخلاص الشهادة من ترويسة التصريح باستخدام الدالة المساعدة getTokenFrom.

إن أردت استخدام الأسلوب ذاته، أعد كتابة الشيفرة التي تأخذ الشهادة إلى أداة وسطية. يجب أن تأخذ الأداة الوسطية الشهادة من ترويسة التصريح وتضعها في الحقل token من كائن الطلب request. يعني هذا: إن صرّحت عن الأداة الوسطية في بداية الملف app.js وقبل كل المسارات كالتالي:

app.use(middleware.tokenExtractor)

ستتمكن المسارات من الوصول إلى الشهادة باستخدام الأمر request.token:

blogsRouter.post('/', async (request, response) => {
  // ..
  const decodedToken = jwt.verify(request.token, process.env.SECRET)
  // ..
})

وتذكر أن الأداة الوسطية هي دالة من ثلاث معاملات، تستدعى في نهاية تنفيذها المعامل الأخير next ليمرر التنفيذ إلى الأداة الوسطية التالية:

const tokenExtractor = (request, response, next) => {
  // code that extracts the token

  next()
}

4.21 التوسع في قائمة المدونات: الخطوة 9 *

عدّل في عملية حذف مدونة بحيث تحذف من قبل منشئها فقط. وبالتالي ستنفذ عملية الحذف إن كانت الشهادة المرسلة عبر الطلب مطابقة لشهادة المستخدم الذي أنشأ المدونة.

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

لاحظ أنك لو أحضرت المدونة من قاعدة البيانات كالتالي:

const blog = await Blog.findById(...)

وأردت أن توازن بين معرف الكائن الذي أحضرته من قاعدة البيانات والقيمة النصية للمعرف، فلن تنجح عملية الموازنة العادية. ذلك أن الحقل blog.user ليس قيمة نصية بل كائن. لذلك لابد من تحويل قيمة المعرف في الكائن الذي أحضر من قاعدة البيانات إلى قيمة نصية أولًا.

if ( blog.user.toString() === userid.toString() ) ...

4.22 التوسع في قائمة المدونات: الخطوة 10 *

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

اطلع على بعض الأفكار المفيدة التي قد تساعدك في إصلاح الاختبارات.

هكذا نكون قد وصلنا إلى التمرين الأخير في القسم، وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى أنك سلمت التمارين المنتهية ضمن منظومة تسليم التمارين.

ترجمة -وبتصرف- للفصل Token authentication من سلسلة Deep Dive Into Modern Web Development





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


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



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن