ניהול סשנים עם קובצי שירות (service worker)

השירות Firebase Auth מאפשר להשתמש ב-Service Worker כדי לזהות ולהעביר אסימונים מזהים של Firebase לניהול סשנים. זה מספק את היתרונות הבאים:

  • יכולת להעביר אסימון מזהה בכל בקשת HTTP מהשרת ללא עבודה נוספת.
  • יכולת לרענן את האסימון המזהה ללא צורך בפעולות הלוך ושוב נוספות של זמן האחזור.
  • סשנים מסונכרנים בקצה העורפי ובקצה הקדמי. אפליקציות שנדרשת להן גישה שירותי Firebase כמו מסדי נתונים בזמן אמת, Firestore וכו', וגם שירותים חיצוניים משאב בצד השרת (מסד נתונים של SQL וכו') יכול להשתמש בפתרון הזה. בנוסף, אפשר לגשת לאותו הסשן גם מה-Service Worker, עובד אינטרנט או עובד משותף.
  • לא צריך יותר לכלול קוד מקור של Firebase Auth בכל דף (מפחית את זמן האחזור). ה-Service Worker, שנטען והופעל פעם אחת, לטפל בניהול הסשנים לכל הלקוחות ברקע.

סקירה כללית

האימות ב-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 גוף וכו'

ייתכן שלא תהיה אפשרות לבצע את הפעולה הזאת בקנה מידה נרחב, ובמקום זאת ייתכן שיהיה צורך בקובצי cookie של סשן בצד השרת. אפשר להגדיר אסימונים מזהים כקובצי cookie של סשן, אבל הם לטווח קצר צריך לרענן אותן מהלקוח ואז להגדיר אותן כקובצי Cookie חדשים עם התפוגה עשויה לחייב נסיעה הלוך ושוב נוספת אם המשתמש לא ביקר זמן מה.

אומנם השיטה Firebase Auth מספקת פתרון לניהול סשנים שמבוסס על קובצי cookie, הפתרון הזה עובד בצורה הטובה ביותר לאפליקציות שמבוססות על קובצי cookie httpOnly בצד השרת וקשה יותר לנהל אותו, כי אסימוני הלקוח והאסימונים בצד השרת יכולים לקבל לא מסונכרן, במיוחד אם צריך להשתמש גם ב-Firebase אחר שמבוסס על לקוח שירותים שונים.

במקום זאת, אפשר להשתמש ב-Service Worker כדי לנהל סשנים של משתמשים בצד השרת צריכה. זה עובד מהסיבות הבאות:

  • ל-Service Workers יש גישה למצב האימות הנוכחי של Firebase. הערך הנוכחי ניתן לאחזר אסימון מזהה של משתמש מ-Service Worker. אם האסימון הוא פג התוקף של ערכת ה-SDK של הלקוח, וה-SDK של הלקוח ירענן אותו ויחזיר ערכת SDK חדשה.
  • קובצי שירות (service worker) יכולים ליירט בקשות לאחזור ולשנות אותן.

שינויים בקובצי שירות (service worker)

ה-Service Worker יצטרך לכלול את ספריית האימות ואת היכולת לקבל את האסימון המזהה הנוכחי אם המשתמש מחובר לחשבון.

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

כל בקשות האחזור למקור האפליקציה יירוטו ואם אסימון מזהה זמין, מצורף לבקשה דרך הכותרת. בצד השרת, בקשה הכותרות ייבדקו עבור אסימון המזהה, ויאומתו ויעובדו. בסקריפט של Service Worker יתבצע יירוט של בקשת האחזור שונה.

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

לכן, לכל הבקשות המאומתות תמיד יהיה אסימון מזהה שמועבר בכותרת ללא עיבוד נוסף.

כדי שה-Service Worker יוכל לזהות שינויים במצב האימות, הוא צריך מותקנת בדף הכניסה/ההרשמה. צריך לוודא שהאלמנטים Service Worker נכלל בחבילה כך שהוא ימשיך לפעול אחרי שהדפדפן נסגר.

אחרי ההתקנה, עובד השירות צריך לבצע קריאה ל-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: '/'});
}

כאשר המשתמש נכנס ומופנה לדף אחר, ה-Service Worker. יוכל להחדיר את האסימון המזהה בכותרת לפני שההפניה האוטומטית תסתיים.

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

שינויים בצד השרת

הקוד בצד השרת יוכל לזהות את האסימון המזהה בכל בקשה. הזה יש תמיכה בהתנהגות של Admin 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');
    }

    // ...
}

סיכום

בנוסף, מכיוון שאסימונים מזהים יוגדרו באמצעות Service Worker והשירות ההפעלה של עובדים מאותו מקור מוגבלת, אין סיכון ל-CSRF כי אתר ממקור אחר שמנסה לקרוא לנקודות הקצה נכשלה ההפעלה של קובץ השירות (service worker), וכתוצאה מכך הבקשה מופיעה לא מאומת מבחינת השרת.

אמנם קובצי שירות (service worker) נתמכים כעת בכל הדפדפנים המתקדמים, אך דפדפנים ישנים יותר אינם תומכים בהם. כתוצאה מכך, חלופה מסוימת עשויה להיות נדרש כדי להעביר את האסימון המזהה לשרת כשאין עובדי שירות או שאפשר להגביל את ההפעלה של אפליקציה מסוימת רק בדפדפנים שתומכים בכך קובצי שירות (service worker).

חשוב לשים לב שעובדי שירותים הם ממקור יחיד בלבד והם יותקנו רק באתרים שמוצגים באמצעות חיבור https או Localhost.

מידע נוסף על תמיכה בדפדפן ל-Service Worker זמין בכתובת caniuse.com.