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

Uwierzytelnianie Firebase pozwala za pomocą mechanizmów Service Worker wykrywać i przekazywać tokeny identyfikatorów Firebase na potrzeby 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 identyfikatora bez dodatkowego przesyłania danych w obie strony i opóźnień.
  • Sesje zsynchronizowane przez backend i frontend. Z tego rozwiązania mogą korzystać aplikacje, które potrzebują dostępu do usług Firebase, takich jak Baza danych czasu rzeczywistego czy Firestore, oraz pewnych zewnętrznych zasobów po stronie serwera (bazy danych SQL). Ta sama sesja jest też dostępna z poziomu skryptu service worker, instancji internetowej lub instancji roboczej udostępnionej.
  • Eliminuje konieczność umieszczania kodu źródłowego Uwierzytelniania Firebase na każdej stronie (zmniejsza to czas oczekiwania). Skrypt service worker, wczytany i zainicjowany raz, obsługiwałby zarządzanie sesjami wszystkich klientów w tle.

Opis

Uwierzytelnianie Firebase jest zoptymalizowane pod kątem działania po stronie klienta. Tokeny są zapisywane w Web Storage. Ułatwia to też integrację z innymi usługami Firebase, takimi jak Baza danych czasu rzeczywistego, Cloud Firestore czy Cloud Storage. Aby zarządzać sesjami z perspektywy serwera, trzeba pobierać tokeny identyfikatorów i przekazywać je do serwera.

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 pewien skrypt musi zostać uruchomiony po stronie klienta, aby uzyskać najnowszy token identyfikatora, a następnie przekazać go do serwera za pomocą nagłówka żądania, treści POST itd.

Może to nie być skalowane i mogą być potrzebne pliki cookie sesji po stronie serwera. Tokeny identyfikatorów mogą być ustawione jako pliki cookie sesji, ale są one krótkotrwałe i trzeba je odświeżyć od klienta, a potem skonfigurować jako nowe pliki cookie po wygaśnięciu. Jeśli użytkownik nie odwiedził witryny przez dłuższy czas, może to wymagać dodatkowej podróży w obie strony.

Chociaż Uwierzytelnianie Firebase to bardziej tradycyjne rozwiązanie do zarządzania sesjami oparte na plikach cookie, to rozwiązanie najlepiej sprawdza się w przypadku aplikacji opartych na plikach cookie httpOnly po stronie serwera i trudniejsze jest zarządzanie, ponieważ tokeny klienta i tokeny po stronie serwera mogą nie być zsynchronizowane, zwłaszcza jeśli musisz korzystać też z innych usług Firebase opartych na kliencie.

Zamiast tego można używać mechanizmów Service Worker do zarządzania sesjami użytkowników na potrzeby korzystania z usług po stronie serwera. Dzieje się tak, ponieważ:

  • Skrypty service worker mają dostęp do bieżącego stanu uwierzytelnienia 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 skryptu service worker

Skrypt service worker musi zawierać bibliotekę uwierzytelniania i możliwość pobrania bieżącego tokena 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 źródła aplikacji zostaną przechwycone i – jeśli będzie dostępny token identyfikatora – dołączony do żądania w nagłówku. Po stronie serwera nagłówki żądań są sprawdzane pod kątem tokena identyfikatora, a następnie weryfikowane i przetwarzane. 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 będą miały w nagłówku przekazywany token tożsamości bez dodatkowego przetwarzania.

Aby skrypt service worker wykrywał zmiany stanu uwierzytelniania, musi być zainstalowany na stronie logowania/rejestracji. Dopilnuj, aby mechanizm Service Worker został połączony w pakiecie, aby nadal działał po zamknięciu przeglądarki.

Po instalacji skrypt usługi musi przy aktywacji wywołać funkcję clients.claim(), aby można ją było skonfigurować jako kontroler 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 na stronie logowania/rejestracji po stronie klienta.

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ę, mechanizm 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. To działanie jest obsługiwane przez pakiet Admin SDK do środowiska Node.js lub przez pakiet internetowy z użyciem 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

Poza tym tokeny identyfikatorów są ustawiane przez mechanizmy Service Worker, a są one ograniczone do uruchamiania z tego samego źródła, więc nie istnieje ryzyko wystąpienia CSRF, ponieważ witryna innego źródła próbująca wywołać swoje punkty końcowe nie wywoła mechanizmu Service Worker, przez co żądanie będzie wyglądało na nieuwierzytelnione z perspektywy serwera.

Skrypty service worker są obecnie obsługiwane we wszystkich nowoczesnych przeglądarkach, jednak niektóre starsze nie obsługują tych mechanizmów. W związku z tym może być konieczne przekazanie tokena identyfikatora na serwer w sytuacji, gdy mechanizmy Service Worker nie są dostępne lub można ograniczyć działanie aplikacji tylko do przeglądarek, które obsługują mechanizmy Service Worker.

Instancje service worker działają tylko z jednym źródłem i są instalowane wyłącznie w witrynach udostępnianych przez połączenie https lub host lokalny.

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