Pengelolaan sesi dengan pekerja layanan

Dengan Firebase Auth, pekerja layanan dapat digunakan untuk mendeteksi dan meneruskan token Firebase ID untuk pengelolaan sesi. Hal ini memberikan manfaat sebagai berikut:

  • Kemampuan untuk meneruskan token ID dari server pada setiap permintaan HTTP tanpa pekerjaan tambahan apa pun.
  • Kemampuan untuk me-refresh token ID tanpa perjalanan dua arah atau latensi tambahan.
  • Sesi backend dan frontend yang disinkronkan. Aplikasi yang memerlukan akses ke layanan Firebase, seperti Realtime Database, Firestore, dll., serta beberapa resource sisi server eksternal (database SQL, dll.) dapat menggunakan solusi ini. Selain itu, sesi yang sama juga dapat diakses dari pekerja layanan, pekerja web, atau pekerja bersama.
  • Tidak perlu lagi menyertakan kode sumber Firebase Auth di setiap halaman (mengurangi latensi). Pekerja layanan, yang dimuat dan diinisialisasi satu kali, akan menangani pengelolaan sesi untuk semua klien di latar belakang.

Ringkasan

Firebase Auth dioptimalkan untuk dijalankan di sisi klien. Token disimpan di penyimpanan web. Hal ini juga memudahkan integrasi dengan layanan Firebase lainnya, seperti Realtime Database, Cloud Firestore, Cloud Storage, dll. Untuk mengelola sesi dari perspektif sisi server, token ID harus diambil dan diteruskan ke server.

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

API dengan namespace web

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

Namun, hal ini juga berarti ada skrip yang harus dijalankan dari sisi klien untuk mendapatkan token ID terbaru, lalu meneruskannya ke server melalui header permintaan, isi POST, dll.

Tindakan ini mungkin tidak terskalakan dan memerlukan cookie sesi sisi server. Token ID dapat ditetapkan sebagai cookie sesi, tetapi hanya bersifat sementara dan harus di-refresh dari sisi klien, kemudian ditetapkan sebagai cookie baru saat masa berlakunya sudah habis. Hal ini mungkin akan memerlukan perjalanan dua arah tambahan jika pengguna sudah lama tidak mengunjungi situs.

Meskipun Firebase Auth menyediakan solusi pengelolaan sesi berbasis cookie yang lebih tradisional, solusi ini berfungsi paling baik untuk aplikasi berbasis cookie httpOnly sisi server dan lebih sulit dikelola karena token klien dan token sisi server dapat tidak sinkron, terutama jika Anda perlu menggunakan layanan Firebase lain yang berbasis klien.

Akan tetapi, pekerja layanan dapat digunakan untuk mengelola sesi pengguna untuk penggunaan sisi server. Hal ini berfungsi karena hal-hal berikut:

  • Pekerja layanan dapat mengakses status Firebase Auth saat ini. Token ID pengguna saat ini dapat diambil dari pekerja layanan. Jika masa berlaku token telah habis, SDK klien akan me-refresh token tersebut dan menampilkan token baru.
  • Pekerja layanan dapat menangkap permintaan pengambilan dan mengubahnya.

Perubahan pekerja layanan

Pekerja layanan harus mencakup library Auth dan kemampuan untuk mendapatkan token ID saat ini jika pengguna login.

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

API dengan namespace 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);
      }
    });
  });
};

Semua permintaan pengambilan yang diajukan ke asal aplikasi akan ditangkap dan ditambahkan ke permintaan melalui header jika token ID tersedia. Di sisi server, header permintaan akan diperiksa token ID-nya, diverifikasi, dan diproses. Dalam skrip pekerja layanan, permintaan pengambilan akan ditangkap dan diubah.

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

API dengan namespace 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));
});

Akibatnya, semua permintaan yang diautentikasi akan selalu memiliki token ID yang diteruskan di header tanpa pemrosesan tambahan.

Pekerja layanan harus diinstal, biasanya di halaman login/pendaftaran, agar dapat mendeteksi perubahan status Auth. Setelah diinstal, pekerja layanan harus memanggil clients.claim() saat diaktivasi sehingga dapat disiapkan sebagai pengontrol untuk halaman saat ini.

API modular web

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

API dengan namespace web

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

Perubahan sisi klien

Pekerja layanan, jika didukung, harus diinstal di halaman login/pendaftaran sisi klien.

API modular web

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

API dengan namespace web

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

Ketika pengguna login dan dialihkan ke halaman lain, pekerja layanan akan dapat menyisipkan token ID di header sebelum pengalihan selesai.

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

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

Perubahan sisi server

Kode sisi server akan dapat mendeteksi token ID pada setiap permintaan. Hal ini digambarkan dalam kode contoh Node.js Express berikut.

// Server side code.
const admin = require('firebase-admin');
const serviceAccount = require('path/to/serviceAccountKey.json');

// The Firebase Admin SDK is used here to verify the ID token.
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

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

Kesimpulan

Harap diperhatikan bahwa token ID akan ditetapkan melalui pekerja layanan, dan pekerja layanan dibatasi agar berjalan dari asal yang sama. Oleh karena itu, tidak ada risiko CSRF karena situs dengan asal berbeda yang mencoba menghubungi endpoint Anda akan gagal memanggil pekerja layanan dan menyebabkan permintaan tersebut terlihat seperti tidak terautentikasi dari perspektif server.

Kini, pekerja layanan didukung di semua browser utama modern, tetapi beberapa browser lama tidak mendukungnya. Akibatnya, mungkin diperlukan metode alternatif untuk meneruskan token ID ke server Anda ketika pekerja layanan tidak tersedia, atau aplikasi dapat dibatasi agar hanya berjalan di browser yang mendukung pekerja layanan.

Perhatikan bahwa pekerja layanan hanya memiliki satu asal dan hanya akan diinstal di situs yang disalurkan melalui koneksi https atau localhost.

Pelajari lebih lanjut dukungan browser untuk pekerja layanan di caniuse.com.