توفّر "مصادقة Firebase" إمكانية استخدام عاملي الخدمة لرصد رموز تعريف Firebase وتمريرها لإدارة الجلسات. ويوفّر ذلك المزايا التالية:
- إمكانية تمرير رمز تعريف في كل طلب HTTP من الخادم بدون أي عمل إضافي
- إمكانية تجديد رمز التعريف بدون أي ذهاب وعودة إضافية أو حالات تأخير
- جلسات متزامنة من جهة الخادم ومن جهة العميل يمكن للتطبيقات التي تحتاج إلى الوصول إلى خدمات Firebase، مثل قاعدة بيانات الوقت الفعلي وFirestore وما إلى ذلك، وبعض الموارد الخارجية من جهة الخادم (قاعدة بيانات SQL وما إلى ذلك) استخدام هذا الحل. بالإضافة إلى ذلك، يمكن أيضًا الوصول إلى الجلسة نفسها من عامل الخدمة أو عامل الويب أو العامل المشترَك.
- لا حاجة إلى تضمين رمز مصدر "مصادقة Firebase" في كل صفحة (ما يقلّل من حالات التأخير) سيتولّى عامل الخدمة، الذي يتم تحميله وتهيئته مرة واحدة، إدارة الجلسات لجميع العملاء في الخلفية.
نظرة عامة
تم تحسين "مصادقة Firebase" للتشغيل من جهة العميل. يتم حفظ الرموز المميّزة في مساحة تخزين الويب. يسهّل ذلك أيضًا التكامل مع خدمات Firebase الأخرى، مثل قاعدة بيانات الوقت الفعلي و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('/'));
واجهة برمجة التطبيقات المعيارية للويب
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 أو localhost.
مزيد من المعلومات حول مدى توافق المتصفّحات مع عامل الخدمة على caniuse.com.
روابط مفيدة
- لمزيد من المعلومات حول استخدام مشغّلي الخدمات لإدارة الجلسات، اطّلِع على الرمز المصدر للتطبيق النموذجي على GitHub.
- يتوفّر نموذج تطبيق تم نشره أعلاه على https://auth-service-worker.appspot.com