إدارة الجلسة مع عمال الخدمة

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

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

ملخص

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

Web modular API

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 namespaced API

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

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

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

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

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

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

تغييرات عامل الخدمة

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

Web modular API

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 namespaced API

// 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 modular API

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 namespaced API

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 modular API

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

Web namespaced API

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

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

يجب تثبيت عامل الخدمة، إذا كان مدعومًا، على صفحة تسجيل الدخول/التسجيل من جانب العميل.

Web modular API

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

Web namespaced API

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

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

Web modular API

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 namespaced API

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

تغييرات جانب الخادم

سيتمكن الرمز الموجود على جانب الخادم من اكتشاف رمز المعرف في كل طلب. تم توضيح ذلك في نموذج التعليمات البرمجية Node.js Express التالي.

// Server side code.
const admin = require('firebase-admin');
const serviceAccount = require('path/to/serviceAccountKey.json');

// The Firebase Admin SDK is used here to verify the ID token.
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

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

خاتمة

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

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

لاحظ أن عمال الخدمات هم من أصل واحد فقط وسيتم تثبيتهم فقط على مواقع الويب التي يتم تقديمها عبر اتصال https أو المضيف المحلي.

تعرف على المزيد حول دعم المتصفح لعامل الخدمة على caniuse.com .