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

Uwierzytelnianie Firebase umożliwia korzystanie z mechanizmów Service Worker do wykrywania i przekazywania tokenów identyfikatora Firebase na potrzeby zarządzania sesjami. Dzięki temu zyskujesz te korzyści:

  • Możliwość przekazywania tokena identyfikatora w każdym żądaniu HTTP z serwera bez dodatkowej pracy.
  • Możliwość odświeżania tokena identyfikatora bez dodatkowych podróży w obie strony i opóźnień.
  • Synchronizacja sesji po stronie backendu i frontendu. Z tego rozwiązania mogą korzystać aplikacje, które muszą mieć dostęp do usług Firebase, takich jak Baza danych czasu rzeczywistego, Firestore itp., oraz do niektórych zasobów zewnętrznych po stronie serwera (baza danych SQL itp.). Ponadto do tej samej sesji można też uzyskać dostęp z mechanizmu Service Worker, Web Worker lub Shared Worker.
  • Eliminuje konieczność umieszczania kodu źródłowego uwierzytelniania Firebase na każdej stronie (zmniejsza opóźnienie). Mechanizm Service Worker, który jest wczytywany i inicjowany tylko raz, będzie obsługiwał zarządzanie sesjami wszystkich klientów w tle.

Przegląd

Uwierzytelnianie Firebase jest zoptymalizowane pod kątem działania po stronie klienta. Tokeny są zapisywane w pamięci przeglądarki. Ułatwia to też integrację z innymi usługami Firebase, takimi jak Baza danych czasu rzeczywistego, Cloud Firestore, Cloud Storage itp. Aby zarządzać sesjami z perspektywy serwera, należy pobrać tokeny identyfikatora i przekazać je 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 po stronie klienta musi działać skrypt, który pobierze najnowszy token identyfikatora, a następnie przekaże go na serwer za pomocą nagłówka żądania, treści POST itp.

Może to nie być skalowalne i zamiast tego mogą być potrzebne pliki cookie sesji po stronie serwera. Tokeny identyfikatora można ustawić jako pliki cookie sesji, ale mają one krótki czas życia i trzeba je odświeżać po stronie klienta, a następnie ustawiać jako nowe pliki cookie po wygaśnięciu. Może to wymagać dodatkowej podróży w obie strony, jeśli użytkownik nie odwiedził witryny od dłuższego czasu.

Uwierzytelnianie Firebase udostępnia bardziej tradycyjne rozwiązanie do zarządzania sesjami oparte na plikach cookie, ale to rozwiązanie najlepiej sprawdza się 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ą się rozsynchronizować, zwłaszcza jeśli musisz też korzystać z innych usług Firebase po stronie klienta.

Zamiast tego do zarządzania sesjami użytkowników na potrzeby serwera można używać mechanizmów Service Worker. Działa to z tych powodów:

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

Zmiany w mechanizmie Service Worker

Mechanizm Service Worker będzie musiał zawierać bibliotekę uwierzytelniania i możliwość pobierania 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 do źródła aplikacji będą przechwytywane, a jeśli dostępny jest token identyfikatora, zostanie on dołączony do żądania w nagłówku. Po stronie serwera nagłówki żądań będą sprawdzane pod kątem tokena identyfikatora, weryfikowane i przetwarzane. W skrypcie mechanizmu 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));
});

Dzięki temu wszystkie uwierzytelnione żądania będą zawsze zawierać token identyfikatora przekazywany w nagłówku bez dodatkowego przetwarzania.

Aby mechanizm Service Worker mógł wykrywać zmiany stanu uwierzytelniania, musi być zainstalowany na stronie logowania lub rejestracji. Upewnij się, że skrypt service worker jest spakowany, aby działał nawet po zamknięciu przeglądarki.

Po zainstalowaniu mechanizm Service Worker musi wywołać metodę clients.claim() podczas aktywacji, aby można go 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, jeśli jest obsługiwany, musi być zainstalowany na stronie logowania lub 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 przekierowany na inną stronę, mechanizm Service Worker będzie mógł wstawić token identyfikatora do nagłówka 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 mógł wykryć token identyfikatora w każdym żądaniu. To zachowanie jest obsługiwane przez pakiet Admin SDK dla Node.js lub pakiet Web SDK za pomocą 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('/'));

Modułowy interfejs API sieci

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 identyfikatora będą ustawiane za pomocą mechanizmów Service Worker, a mechanizmy Service Worker mogą działać tylko z tego samego źródła, nie ma ryzyka CSRF. Witryna z innego źródła, która próbuje wywołać Twoje punkty końcowe, nie będzie mogła wywołać mechanizmu Service Worker, co spowoduje, że żądanie będzie wyglądać z perspektywy serwera jako nieuwierzytelnione.

Mechanizmy Service Worker są obecnie obsługiwane we wszystkich nowoczesnych przeglądarkach, ale niektóre starsze przeglądarki ich nie obsługują. W związku z tym może być potrzebne rozwiązanie rezerwowe, które będzie przekazywać token identyfikatora na serwer, gdy mechanizmy Service Worker są niedostępne. Można też ograniczyć działanie aplikacji tylko do przeglądarek, które obsługują mechanizmy Service Worker.

Pamiętaj, że mechanizmy Service Worker działają tylko w jednym źródle i będą instalowane tylko w witrynach udostępnianych przez połączenie https lub localhost.

Więcej informacji o obsłudze skryptów service worker w przeglądarkach znajdziesz na stronie caniuse.com.