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

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

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

نظرة عامة

تم تحسين مصادقة Firebase للتشغيل من جانب العميل. يتم حفظ الرموز المميّزة في التخزين على الويب. يسهّل ذلك أيضًا الدمج مع خدمات Firebase الأخرى. مثل قاعدة بيانات الوقت الفعلي و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.