إدارة الجلسات مع العاملين في الخدمات

توفّر مصادقة Firebase إمكانية الاستعانة بمشغّلي الخدمة لرصد رموز معرّف Firebase وتمريرها لإدارة الجلسة. ويوفّر ذلك المزايا التالية:

  • إمكانية تمرير رمز مميز للمعرّف في كل طلب HTTP من الخادم بدون أيّ إجراء إضافي
  • إمكانية تحديث الرمز المميز للمعرّف بدون أي رحلات ذهاب وعودة إضافية أو أوقات استجابة.
  • جلسات تتم مزامنتها في الخلفية والواجهة الأمامية. ويمكن للتطبيقات التي تحتاج إلى الوصول إلى خدمات Firebase، مثل قاعدة البيانات في الوقت الفعلي وFirestore وما إلى ذلك، وبعض الموارد الخارجية للخادم (قاعدة بيانات SQL، وغيرها) استخدام هذا الحل. بالإضافة إلى ذلك، يمكن أيضًا الوصول إلى الجلسة نفسها من مشغّل الخدمات أو عامل الويب أو العامل المشترك.
  • لن تحتاج إلى تضمين رمز مصدر مصادقة Firebase في كل صفحة (ما يؤدي إلى تقليل وقت الاستجابة). سيتولى عامل الخدمة، الذي تم تحميله وإعداده مرة واحدة، إدارة الجلسة لجميع العملاء في الخلفية.

نظرة عامة

تم تحسين مصادقة Firebase لتعمل من جهة العميل. يتم حفظ الرموز المميزة في مساحة تخزين الويب. وهذا يسهل أيضًا الدمج مع خدمات Firebase الأخرى مثل قاعدة البيانات في الوقت الفعلي وCloud Firestore وCloud Storage وما إلى ذلك. لإدارة الجلسات من منظور الخادم، يجب استرداد رموز المعرّف المميزة وتمريرها إلى الخادم.

واجهة برمجة تطبيقات الويب النموذجية

import { getAuth, getIdToken } from "firebase/auth";

const auth = getAuth();
getIdToken(auth.currentUser)
  .then((idToken) => {
    // idToken can be passed back to server.
  })
  .catch((error) => {
    // Error occurred.
  });

واجهة برمجة تطبيقات مساحات الاسم على الويب

firebase.auth().currentUser.getIdToken()
  .then((idToken) => {
    // idToken can be passed back to server.
  })
  .catch((error) => {
    // Error occurred.
  });

مع ذلك، يعني هذا أنّه يجب تشغيل نص برمجي من العميل للحصول على أحدث رمز للمعرف ثم تمريره إلى الخادم عبر عنوان الطلب ونص POST وما إلى ذلك.

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

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

بدلاً من ذلك، يمكن الاستعانة بعاملي الخدمة لإدارة جلسات المستخدمين للاستهلاك من جهة الخادم. وينجح ذلك للأسباب التالية:

  • يمكن للعاملين في الخدمة الوصول إلى حالة المصادقة الحالية في Firebase. يمكن استرداد الرمز المميز لرقم تعريف المستخدم الحالي من مشغّل الخدمات. إذا انتهت صلاحية الرمز المميّز، ستعمل حزمة تطوير البرامج (SDK) للعميل على تحديثه وعرض رمز جديد.
  • يمكن لمشغِّلي الخدمات اعتراض طلبات الجلب وتعديلها.

تغييرات مشغّل الخدمات

سيحتاج مشغّل الخدمات إلى تضمين مكتبة المصادقة وإمكانية الحصول على الرمز المميز للمعرّف الحالي في حال تسجيل دخول المستخدم.

واجهة برمجة تطبيقات الويب النموذجية

import { initializeApp } from "firebase/app";
import { getAuth, onAuthStateChanged, getIdToken } from "firebase/auth";

// Initialize the Firebase app in the service worker script.
initializeApp(config);

/**
 * Returns a promise that resolves with an ID token if available.
 * @return {!Promise<?string>} The promise that resolves with an ID token if
 *     available. Otherwise, the promise resolves with null.
 */
const auth = getAuth();
const getIdTokenPromise = () => {
  return new Promise((resolve, reject) => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      unsubscribe();
      if (user) {
        getIdToken(user).then((idToken) => {
          resolve(idToken);
        }, (error) => {
          resolve(null);
        });
      } else {
        resolve(null);
      }
    });
  });
};

واجهة برمجة تطبيقات مساحات الاسم على الويب

// Initialize the Firebase app in the service worker script.
firebase.initializeApp(config);

/**
 * Returns a promise that resolves with an ID token if available.
 * @return {!Promise<?string>} The promise that resolves with an ID token if
 *     available. Otherwise, the promise resolves with null.
 */
const getIdToken = () => {
  return new Promise((resolve, reject) => {
    const unsubscribe = firebase.auth().onAuthStateChanged((user) => {
      unsubscribe();
      if (user) {
        user.getIdToken().then((idToken) => {
          resolve(idToken);
        }, (error) => {
          resolve(null);
        });
      } else {
        resolve(null);
      }
    });
  });
};

سيتم اعتراض جميع طلبات الجلب إلى مصدر التطبيق، وفي حال توفُّر رمز مميز للمعرّف، يتم إلحاقه بالطلب من خلال العنوان. من جهة الخادم، سيتم فحص رؤوس الطلبات للرمز المميز للمعرّف، والتحقق منها ومعالجتها. في النص البرمجي لمشغِّل الخدمات، سيتم اعتراض طلب الاسترجاع وتعديله.

واجهة برمجة تطبيقات الويب النموذجية

const getOriginFromUrl = (url) => {
  // https://stackoverflow.com/questions/1420881/how-to-extract-base-url-from-a-string-in-javascript
  const pathArray = url.split('/');
  const protocol = pathArray[0];
  const host = pathArray[2];
  return protocol + '//' + host;
};

// Get underlying body if available. Works for text and json bodies.
const getBodyContent = (req) => {
  return Promise.resolve().then(() => {
    if (req.method !== 'GET') {
      if (req.headers.get('Content-Type').indexOf('json') !== -1) {
        return req.json()
          .then((json) => {
            return JSON.stringify(json);
          });
      } else {
        return req.text();
      }
    }
  }).catch((error) => {
    // Ignore error.
  });
};

self.addEventListener('fetch', (event) => {
  /** @type {FetchEvent} */
  const evt = event;

  const requestProcessor = (idToken) => {
    let req = evt.request;
    let processRequestPromise = Promise.resolve();
    // For same origin https requests, append idToken to header.
    if (self.location.origin == getOriginFromUrl(evt.request.url) &&
        (self.location.protocol == 'https:' ||
         self.location.hostname == 'localhost') &&
        idToken) {
      // Clone headers as request headers are immutable.
      const headers = new Headers();
      req.headers.forEach((val, key) => {
        headers.append(key, val);
      });
      // Add ID token to header.
      headers.append('Authorization', 'Bearer ' + idToken);
      processRequestPromise = getBodyContent(req).then((body) => {
        try {
          req = new Request(req.url, {
            method: req.method,
            headers: headers,
            mode: 'same-origin',
            credentials: req.credentials,
            cache: req.cache,
            redirect: req.redirect,
            referrer: req.referrer,
            body,
            // bodyUsed: req.bodyUsed,
            // context: req.context
          });
        } catch (e) {
          // This will fail for CORS requests. We just continue with the
          // fetch caching logic below and do not pass the ID token.
        }
      });
    }
    return processRequestPromise.then(() => {
      return fetch(req);
    });
  };
  // Fetch the resource after checking for the ID token.
  // This can also be integrated with existing logic to serve cached files
  // in offline mode.
  evt.respondWith(getIdTokenPromise().then(requestProcessor, requestProcessor));
});

واجهة برمجة تطبيقات مساحات الاسم على الويب

const getOriginFromUrl = (url) => {
  // https://stackoverflow.com/questions/1420881/how-to-extract-base-url-from-a-string-in-javascript
  const pathArray = url.split('/');
  const protocol = pathArray[0];
  const host = pathArray[2];
  return protocol + '//' + host;
};

// Get underlying body if available. Works for text and json bodies.
const getBodyContent = (req) => {
  return Promise.resolve().then(() => {
    if (req.method !== 'GET') {
      if (req.headers.get('Content-Type').indexOf('json') !== -1) {
        return req.json()
          .then((json) => {
            return JSON.stringify(json);
          });
      } else {
        return req.text();
      }
    }
  }).catch((error) => {
    // Ignore error.
  });
};

self.addEventListener('fetch', (event) => {
  /** @type {FetchEvent} */
  const evt = event;

  const requestProcessor = (idToken) => {
    let req = evt.request;
    let processRequestPromise = Promise.resolve();
    // For same origin https requests, append idToken to header.
    if (self.location.origin == getOriginFromUrl(evt.request.url) &&
        (self.location.protocol == 'https:' ||
         self.location.hostname == 'localhost') &&
        idToken) {
      // Clone headers as request headers are immutable.
      const headers = new Headers();
      req.headers.forEach((val, key) => {
        headers.append(key, val);
      });
      // Add ID token to header.
      headers.append('Authorization', 'Bearer ' + idToken);
      processRequestPromise = getBodyContent(req).then((body) => {
        try {
          req = new Request(req.url, {
            method: req.method,
            headers: headers,
            mode: 'same-origin',
            credentials: req.credentials,
            cache: req.cache,
            redirect: req.redirect,
            referrer: req.referrer,
            body,
            // bodyUsed: req.bodyUsed,
            // context: req.context
          });
        } catch (e) {
          // This will fail for CORS requests. We just continue with the
          // fetch caching logic below and do not pass the ID token.
        }
      });
    }
    return processRequestPromise.then(() => {
      return fetch(req);
    });
  };
  // Fetch the resource after checking for the ID token.
  // This can also be integrated with existing logic to serve cached files
  // in offline mode.
  evt.respondWith(getIdToken().then(requestProcessor, requestProcessor));
});

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

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

بعد التثبيت، على عامل الخدمة طلب clients.claim() عند التفعيل حتى يمكن إعداده كمسؤول تحكّم في الصفحة الحالية.

واجهة برمجة تطبيقات الويب النموذجية

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

واجهة برمجة تطبيقات مساحات الاسم على الويب

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

التغييرات من جانب العميل

يجب تثبيت عامل الخدمة، إن توفّر، في صفحة تسجيل الدخول/الاشتراك من جهة العميل.

واجهة برمجة تطبيقات الويب النموذجية

// Install servicerWorker if supported on sign-in/sign-up page.
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js', {scope: '/'});
}

واجهة برمجة تطبيقات مساحات الاسم على الويب

// Install servicerWorker if supported on sign-in/sign-up page.
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js', {scope: '/'});
}

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

واجهة برمجة تطبيقات الويب النموذجية

import { getAuth, signInWithEmailAndPassword } from "firebase/auth";

// Sign in screen.
const auth = getAuth();
signInWithEmailAndPassword(auth, email, password)
  .then((result) => {
    // Redirect to profile page after sign-in. The service worker will detect
    // this and append the ID token to the header.
    window.location.assign('/profile');
  })
  .catch((error) => {
    // Error occurred.
  });

واجهة برمجة تطبيقات مساحات الاسم على الويب

// Sign in screen.
firebase.auth().signInWithEmailAndPassword(email, password)
  .then((result) => {
    // Redirect to profile page after sign-in. The service worker will detect
    // this and append the ID token to the header.
    window.location.assign('/profile');
  })
  .catch((error) => {
    // Error occurred.
  });

التغييرات من جهة الخادم

سيتمكن الرمز من جهة الخادم من اكتشاف الرمز المميز للمعرّف في كل طلب. تتوافق هذه السلوك مع حزمة تطوير البرامج (SDK) للمشرف في Node.js أو في حزمة تطوير البرامج (SDK) للويب باستخدام FirebaseServerApp.

Node.js

  // Server side code.
  const admin = require('firebase-admin');

  // The Firebase Admin SDK is used here to verify the ID token.
  admin.initializeApp();

  function getIdToken(req) {
    // Parse the injected ID token from the request header.
    const authorizationHeader = req.headers.authorization || '';
    const components = authorizationHeader.split(' ');
    return components.length > 1 ? components[1] : '';
  }

  function checkIfSignedIn(url) {
    return (req, res, next) => {
      if (req.url == url) {
        const idToken = getIdToken(req);
        // Verify the ID token using the Firebase Admin SDK.
        // User already logged in. Redirect to profile page.
        admin.auth().verifyIdToken(idToken).then((decodedClaims) => {
          // User is authenticated, user claims can be retrieved from
          // decodedClaims.
          // In this sample code, authenticated users are always redirected to
          // the profile page.
          res.redirect('/profile');
        }).catch((error) => {
          next();
        });
      } else {
        next();
      }
    };
  }

  // If a user is signed in, redirect to profile page.
  app.use(checkIfSignedIn('/'));

واجهة برمجة تطبيقات الويب النموذجية

import { initializeServerApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default function MyServerComponent() {

    // Get relevant request headers (in Next.JS)
    const authIdToken = headers().get('Authorization')?.split('Bearer ')[1];

    // Initialize the FirebaseServerApp instance.
    const serverApp = initializeServerApp(firebaseConfig, { authIdToken });

    // Initialize Firebase Authentication using the FirebaseServerApp instance.
    const auth = getAuth(serverApp);

    if (auth.currentUser) {
        redirect('/profile');
    }

    // ...
}

الخلاصة

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

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

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

اطّلِع على مزيد من المعلومات حول توافق المتصفّح مع مشغّل الخدمات على caniuse.com.