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

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

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

סקירה כללית

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

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

ממשק API של מרחב שמות באינטרנט

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

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

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

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

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

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

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

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

ממשק 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);
      }
    });
  });
};

ממשק 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);
      }
    });
  });
};

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

ממשק 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));
});

ממשק 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));
});

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

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

לאחר ההתקנה, ה-Service Worker צריך לקרוא ל-clients.claim() בהפעלה כדי להגדיר אותו כבקר בדף הנוכחי.

ממשק API מודולרי באינטרנט

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

ממשק API של מרחב שמות באינטרנט

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

שינויים בצד הלקוח

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

ממשק API מודולרי באינטרנט

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

ממשק API של מרחב שמות באינטרנט

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

כשהמשתמש מחובר לחשבון ומופנה לדף אחר, קובץ השירות (service worker) יוכל להחדיר את האסימון המזהה לכותרת לפני שההפניה מסתיימת.

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

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

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

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

ממשק 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, וה-Service Works מוגבלים להפעלה מאותו מקור, אין סיכון של CSRF כי אתר ממקור שונה שינסה לקרוא לנקודות הקצה שלכם ייכשל להפעיל את Service Worker, וכתוצאה מכך הבקשה תופיע ללא אימות מבחינת השרת.

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

שימו לב ש-service worker הוא מקור יחיד בלבד, והם יותקנו רק באתרים שמופעלים באמצעות חיבור https או Localhost.

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