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

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

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

نظرة عامة

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

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 Auth يوفّر حلًّا تقليديًا لإدارة الجلسات يستند إلى ملفات تعريف الارتباط، يعمل هذا الحلّ بشكل أفضل مع التطبيقات المستندة إلى ملفات تعريف الارتباط httpOnly من جهة الخادم، ويصعب إدارته لأنّه قد لا تتم مزامنة الرموز المميّزة للعملاء والرموز المميّزة من جهة الخادم، خاصةً إذا كنت بحاجة أيضًا إلى استخدام خدمات FirebasehttpOnly الأخرى المستندة إلى العميل.

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

  • يمكن لعمال الخدمة الوصول إلى الحالة الحالية لـ Firebase Auth. يمكن استرداد الرمز المميّز الحالي للمعرّف المستخدِم من الخدمة العاملة في الخلفية. إذا كان الرمز المميّز منتهي الصلاحية، ستُعيد حزمة SDK الخاصة بالعميل تحميله وتعرض رمزًا جديدًا.
  • يمكن لعمال الخدمة اعتراض طلبات الجلب وتعديلها.

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

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

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

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

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

بعد التثبيت، على عامل الخدمة الاتصال بخدمة 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 أو Web 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 لأنّ موقعًا إلكترونيًا من مصدر مختلف يحاول الاتصال بنقطاتك الطرفية لن تتمكّن من استدعاء موظّف الخدمة، ما يؤدي إلى ظهور الطلب غير مُعتمَد من منظور الخادم.

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

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

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