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

Uwierzytelnianie Firebase umożliwia używanie skryptów service worker do wykrywania i przekazywania tokenów identyfikatorów Firebase na potrzeby zarządzania sesją. Dzięki temu:

  • Możliwość przekazywania tokena identyfikatora w przypadku każdego żądania HTTP z serwera bez żadnej dodatkowej pracy.
  • Możliwość odświeżania tokena identyfikatora bez dodatkowych transferów w obie strony i opóźnień.
  • Zsynchronizowane sesje backendu i frontendu. Z tego rozwiązania mogą korzystać aplikacje, które potrzebują dostępu do usług Firebase, takich jak Baza danych czasu rzeczywistego, Firestore itp., a także zasoby zewnętrzne po stronie serwera (baza danych SQL itp.). Dostęp do tej samej sesji można też uzyskać za pomocą skryptu service worker, instancji roboczej lub udostępnionej instancji roboczej.
  • Eliminuje konieczność umieszczania kodu źródłowego Uwierzytelniania Firebase na każdej stronie (zmniejsza czas oczekiwania). Skrypt service worker załadował i zainicjował raz w tle zarządzanie sesjami dla wszystkich klientów.

Przegląd

Uwierzytelnianie Firebase jest zoptymalizowane pod kątem działania po stronie klienta. Tokeny są zapisywane w pamięci internetowej. Ułatwia to również integrację z innymi usługami Firebase, takimi jak Baza danych czasu rzeczywistego, Cloud Firestore, Cloud Storage itp. Aby zarządzać sesjami z perspektywy serwera, trzeba pobrać tokeny tożsamości i przekazać je do serwera.

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

Interfejs API Web Namespaced

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

Oznacza to jednak, że jakiś skrypt musi zostać uruchomiony z klienta, aby uzyskać najnowszy token identyfikatora, a następnie przekazać go na serwer za pomocą nagłówka żądania, treści POST itd.

Może to się nie skalować i zamiast tego mogą być potrzebne pliki cookie sesji po stronie serwera. Tokeny tożsamości można ustawiać jako pliki cookie sesji, ale są one krótkotrwałe i trzeba je odświeżyć przez klienta, a następnie ustawić jako nowe pliki cookie w chwili wygaśnięcia. Może to wymagać dodatkowego obiegu, jeśli użytkownik przez jakiś czas nie odwiedzał witryny.

Uwierzytelnianie Firebase to bardziej tradycyjne rozwiązanie do zarządzania sesjami na podstawie plików cookie, ale sprawdza się ono 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ą przestać być synchronizowane, zwłaszcza jeśli trzeba używać też innych usług Firebase opartych na kliencie.

Skrypty service worker mogą być natomiast używane do zarządzania sesjami użytkowników na potrzeby wykorzystania po stronie serwera. Dzieje się tak, ponieważ:

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

Zmiany w mechanizmach Service Worker

Skrypt service worker musi obejmować bibliotekę Auth oraz możliwość pobrania bieżącego tokena identyfikatora, jeśli użytkownik jest zalogowany.

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

Interfejs API Web Namespaced

// 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 źródła aplikacji będą przechwytywane i, jeśli token identyfikatora jest dostępny, dołączany do żądania za pomocą nagłówka. Po stronie serwera nagłówki żądań będą sprawdzane pod kątem tokena identyfikatora, weryfikowane i przetwarzane. W skrypcie skryptu service worker żądanie pobierania jest przechwytywane i modyfikowane.

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

Interfejs API Web Namespaced

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 związku z tym wszystkie uwierzytelnione żądania zawsze będą miały token identyfikatora przekazywany w nagłówku bez dodatkowego przetwarzania.

Aby skrypt service worker wykrywał zmiany stanu uwierzytelniania, musi być zainstalowany na stronie logowania lub rejestracji. Dopilnuj, aby skrypt service worker był dostępny w pakiecie, tak aby nadal działał po zamknięciu przeglądarki.

Po instalacji mechanizm Service Worker musi podczas aktywacji wywołać clients.claim(), aby można było go skonfigurować jako kontroler bieżącej strony.

Web modular API

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

Interfejs API Web Namespaced

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

Zmiany po stronie klienta

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

Web modular API

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

Interfejs API Web Namespaced

// 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 przekierowany na inną stronę, skrypt service worker może wstrzyknąć token identyfikatora w nagłówku przed zakończeniem przekierowania.

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

Interfejs API Web Namespaced

// 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 wykrywać token identyfikatora przy każdym żądaniu. To działanie jest obsługiwane przez pakiet Admin SDK dla Node.js lub pakiet internetowy SDK korzystający z 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

Ponadto, ponieważ tokeny tożsamości będą ustawiane przez mechanizmy Service Worker, a ich uruchamianie z tego samego źródła będzie ograniczone, nie ma ryzyka CSRF, ponieważ witryna innego pochodzenia próbująca wywołać Twoje punkty końcowe nie uda się wywołać skryptu service worker, co spowoduje, że żądanie będzie wyglądało na nieuwierzytelnione z punktu widzenia serwera.

Mechanizmy service worker są teraz obsługiwane przez wszystkie nowoczesne główne przeglądarki, ale niektóre starsze przeglądarki ich nie obsługują. W związku z tym może być konieczne przekazanie tokena identyfikatora do serwera, gdy mechanizmy Service Worker są niedostępne lub gdy działanie aplikacji będzie możliwe tylko w przeglądarkach, które je obsługują.

Pamiętaj, że mechanizmy Service Worker mają tylko 1 źródło i będą instalowane tylko na stronach udostępnianych przez połączenie HTTPS lub przez hosta lokalnego.

Więcej informacji o obsłudze przeglądarek dla mechanizmów Service Worker znajdziesz na stronie caniuse.com.