Zarządzanie sesjami za pomocą mechanizmów Service Worker

Uwierzytelnianie Firebase umożliwia wykorzystanie mechanizmów Service Worker do wykrywania i przekazywania Tokeny identyfikatorów Firebase do zarządzania sesjami. Dzięki temu:

  • Możliwość przekazywania tokena identyfikatora przy każdym żądaniu HTTP z serwera bez dodatkowej pracy.
  • możliwość odświeżenia tokena tożsamości bez dodatkowej procedury przesyłania danych w obie strony czy i opóźnieniach.
  • Sesje zsynchronizowane przez backend i frontend. Aplikacje, które muszą mieć dostęp Usługi Firebase, takie jak Baza danych czasu rzeczywistego czy Firestore, oraz niektóre zewnętrzne zasób po stronie serwera (baza danych SQL itp.) może korzystać z tego rozwiązania. Dostęp do tej samej sesji można też uzyskać z poziomu skryptu service worker, lub współpracownikiem internetowym.
  • Eliminuje konieczność umieszczania kodu źródłowego Uwierzytelniania Firebase na każdej stronie. (skraca czas oczekiwania). Wczytany i zainicjowany raz skrypt service worker wykonałby obsługuje zarządzanie sesjami wszystkich klientów w tle.

Omówienie

Uwierzytelnianie Firebase jest zoptymalizowane pod kątem działania po stronie klienta. Tokeny są zapisywane w pamięci masowej. Dzięki temu możesz łatwo zintegrować ją z innymi usługami Firebase takich jak Baza danych czasu rzeczywistego, Cloud Firestore, Cloud Storage itp. Aby zarządzać sesjami z perspektywy serwera, tokeny tożsamości muszą być została pobrana i przekazana na serwer.

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

Oznacza to jednak, że aby uzyskać najnowszego identyfikatora, a następnie przekazać go do serwera za pomocą nagłówka żądania POST. treść itd.

Może to nie być skalowane i mogą być potrzebne pliki cookie sesji po stronie serwera. Tokeny tożsamości mogą być ustawione jako pliki cookie sesji, ale są one krótkotrwałe i będą muszą zostać odświeżone po stronie klienta, a po wygaśnięciu ustaw jako nowe pliki cookie co może wymagać dodatkowej podróży w obie strony, jeśli użytkownik nie odwiedził za jakiś czas.

Z kolei Uwierzytelnianie Firebase rozwiązań do zarządzania sesjami opartymi na plikach cookie, to rozwiązanie działa najlepiej w przypadku aplikacji opartych na plikach cookie httpOnly po stronie serwera i jest trudniejsze w zarządzaniu, ponieważ tokeny klienta i tokeny po stronie serwera mogą są niezsynchronizowane, zwłaszcza jeśli musisz użyć też innej platformy Firebase usług Google.

Zamiast tego można używać mechanizmów Service Worker do zarządzania sesjami użytkowników po stronie serwera konsumpcją treści. Dzieje się tak, ponieważ:

  • Skrypty service worker mają dostęp do bieżącego stanu uwierzytelnienia Firebase. Aktualna token identyfikatora użytkownika można pobrać z skryptu service worker. Jeśli token to wygasł, pakiet SDK klienta odświeży go i zwróci nowy.
  • Skrypty service worker mogą przechwytywać żądania pobierania i je modyfikować.

Zmiany skryptu service worker

Skrypt service worker musi zawierać bibliotekę Auth i możliwość pobierania obecny token identyfikatora, jeśli użytkownik jest zalogowany.

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

Wszystkie żądania pobierania wysyłane do punktu początkowego aplikacji zostaną przechwycone, a jeśli token identyfikatora jest dołączany do żądania za pomocą nagłówka. Po stronie serwera, żądanie będą sprawdzane i przetwarzane pod kątem tokena tożsamości. W skrypcie skryptu service worker żądanie pobierania zostanie przechwycone i zmodyfikowane.

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

W efekcie wszystkie uwierzytelnione żądania zawsze otrzymują token tożsamości. do nagłówka bez dodatkowego przetwarzania.

Aby skrypt service worker wykrywał zmiany stanu uwierzytelniania, zainstalowane na stronie logowania/rejestracji. Upewnij się, że skrypt service worker jest w pakiecie, tak aby nadal działał po tym, jak przeglądarka zostało zamknięte.

Po instalacji usługa instancja robocza musi w momencie aktywacji zadzwonić pod numer clients.claim(), aby można było skonfigurować ją jako dla bieżącej strony.

Web

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

Web

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

Zmiany po stronie klienta

Skrypt service worker musi być zainstalowany po stronie klienta, jeśli jest obsługiwany stronie logowania/rejestracji.

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

Gdy użytkownik jest zalogowany i przekierowywany na inną stronę, skrypt service worker może wstrzyknąć token identyfikatora w nagłówku przed zakończeniem przekierowania.

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

Zmiany po stronie serwera

Kod po stronie serwera będzie w stanie wykryć token identyfikatora w każdym żądaniu. Ten jest obsługiwane przez pakiet Admin SDK w środowisku Node.js Pakiet SDK używa platformy 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');
    }

    // ...
}

Podsumowanie

Tokeny identyfikatorów będą ustawiane przez mechanizmy Service Worker, a usługa instancje robocze mogą działać z tym samym źródłem, nie ma ryzyka CSRF bo witryna innego źródła próbująca wywołać punkty końcowe nie wywołuje skryptu service worker, przez co żądanie jest widoczne nieuwierzytelnione z punktu widzenia serwera.

Skrypty service worker są obecnie obsługiwane we wszystkich nowoczesnych przeglądarkach, starsze przeglądarki ich nie obsługują. W efekcie niektóre kreacje zastępcze mogą być potrzebne do przekazania tokena identyfikatora na serwer, gdy mechanizmy Service Worker nie lub aplikacje mogą działać tylko w przeglądarkach, które obsługują mechanizmy Service Worker.

Pamiętaj, że instancje service worker działają tylko z jednego punktu początkowego i będą instalowane tylko w witrynach udostępnianych przez połączenie https lub hosta lokalnego.

Więcej informacji o obsłudze przeglądarki przez mechanizm Service Worker znajdziesz na stronie caniuse.com.