إدارة الجلسات مع مشغّلي الخدمات

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

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

نظرة عامة

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

Web

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.
  });

Web

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

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

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

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

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

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

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

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

Web

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);
      }
    });
  });
};

Web

// 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);
      }
    });
  });
};

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

Web

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));
});

Web

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() عند التفعيل حتى يمكن إعداده كوحدة تحكم للصفحة الحالية.

Web

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

Web

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

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

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

Web

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

Web

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

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

Web

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.
  });

Web

// 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('/'));

واجهة برمجة التطبيقات Web modular API

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.