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

באמצעות Firebase Auth אפשר להשתמש ב-service workers כדי לזהות ולהעביר אסימונים מזהים של Firebase לצורך ניהול סשנים. היתרונות של השיטה הזו:

  • היכולת להעביר אסימון מזהה בכל בקשת HTTP מהשרת בלי צורך בעבודה נוספת.
  • יכולת לרענן את אסימון המזהה ללא זמן נסיעה הלוך ושוב נוסף או זמן אחזור נוסף.
  • סשנים מסונכרנים בקצה העורפי ובקצה הקדמי. אפשר להשתמש בפתרון הזה באפליקציות שצריכות לגשת לשירותי Firebase כמו Realtime Database,‏ Firestore וכו', ולמשאבים חיצוניים מסוימים בצד השרת (מסד נתונים של SQL וכו'). בנוסף, אפשר לגשת לאותה סשן גם מ-service worker, מ-web worker או מ-shared worker.
  • אין צורך לכלול את קוד המקור של אימות Firebase בכל דף (כך מקצרים את זמן האחזור). ה-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, אבל הפתרון הזה מתאים בעיקר לאפליקציות httpOnly מבוססות-קובצי cookie בצד השרת, וקשה יותר לנהל אותו כי האסימונים של הלקוח והאסימונים בצד השרת עלולים לא להיות מסונכרנים, במיוחד אם אתם צריכים להשתמש גם בשירותים אחרים של Firebase מבוססי-לקוח.

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

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

שינויים בקובצי שירות (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);
      }
    });
  });
};

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

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

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

אם יש תמיכה ב-service worker, צריך להתקין אותו בדף הכניסה או ההרשמה בצד הלקוח.

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

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

    // ...
}

סיכום

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

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

חשוב לזכור שקובצי שירות (service workers) הם ממקור יחיד בלבד, והם יותקנו רק באתרים שמוצגים דרך חיבור https או localhost.

מידע נוסף על תמיכת הדפדפנים בקובצי שירות זמין ב-caniuse.com.