與 Service Worker 的會話管理

Firebase Auth 提供了使用 Service Worker 偵測和傳遞 Firebase ID 令牌以進行會話管理的功能。這提供了以下好處:

  • 能夠在來自伺服器的每個 HTTP 請求上傳遞 ID 令牌,而無需任何額外的工作。
  • 能夠刷新 ID 令牌,無需任何額外的往返或延遲。
  • 後端和前端同步會話。需要存取 Firebase 服務(例如即時資料庫、Firestore 等)和一些外部伺服器端資源(SQL 資料庫等)的應用程式可以使用此解決方案。此外,還可以從 Service Worker、Web Worker 或 Shared Worker 存取相同會話。
  • 無需在每個頁面上包含 Firebase Auth 原始碼(減少延遲)。載入並初始化一次的服務工作執行緒將在後台處理所有客戶端的會話管理。

概述

Firebase Auth 經過最佳化,可在客戶端運作。令牌保存在網路儲存中。這使得與其他 Firebase 服務(例如即時資料庫、Cloud Firestore、雲端儲存等)整合變得非常容易。要從伺服器端的角度管理會話,必須檢索 ID 令牌並將其傳遞到伺服器。

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

Web namespaced API

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

然而,這意味著必須從客戶端執行一些腳本來獲取最新的 ID 令牌,然後透過請求標頭、POST 正文等將其傳遞到伺服器。

這可能無法擴展,而是可能需要伺服器端會話 cookie。 ID 令牌可以設定為會話 cookie,但它們的生命週期很短,需要從客戶端刷新,然後在過期時設定為新 cookie,如果使用者有一段時間沒有訪問該站點,則可能需要額外的往返。

雖然 Firebase Auth 提供了更傳統的基於 cookie 的會話管理解決方案,但該解決方案最適合基於伺服器端httpOnly cookie 的應用程序,並且更難管理,因為客戶端令牌和伺服器端令牌可能不同步,特別是如果您還需要使用其他基於客戶端的 Firebase 服務。

相反,服務工作執行緒可用於管理伺服器端消費的使用者會話。這是因為以下原因:

  • Service Worker 可以存取目前的 Firebase Auth 狀態。可以從 Service Worker 檢索目前使用者 ID 令牌。如果 token 過期,客戶端 SDK 將刷新它並傳回一個新的 token。
  • Service Worker 可以攔截獲取請求並修改它們。

服務人員變更

Service Worker 將需要包含 Auth 庫以及在使用者登入時取得目前 ID 令牌的能力。

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

Web namespaced API

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

對應用程式來源的所有取得請求都將被攔截,如果 ID 令牌可用,則透過標頭附加到請求中。伺服器端,將檢查請求標頭中的 ID 令牌,進行驗證和處理。在服務工作者腳本中,提取請求將被攔截並修改。

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

Web namespaced 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(getIdToken().then(requestProcessor, requestProcessor));
});

因此,所有經過身份驗證的請求將始終在標頭中傳遞一個 ID 令牌,而無需進行額外處理。

為了讓 Service Worker 偵測身份驗證狀態更改,通常必須將其安裝在登入/註冊頁面上。安裝後,Service Worker 必須在啟動時呼叫clients.claim() ,以便將其設定為目前頁面的控制器。

Web modular API

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

Web namespaced API

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

客戶端變化

Service Worker(如果支援)需要安裝在客戶端登入/註冊頁面上。

Web modular API

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

Web namespaced API

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

當使用者登入並重新導向到另一個頁面時,服務工作人員將能夠在重定向完成之前將 ID 令牌注入標頭中。

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

Web namespaced API

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

伺服器端的改變

伺服器端程式碼將能夠偵測每個請求的 ID 令牌。以下 Node.js Express 範例程式碼對此進行了說明。

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

結論

此外,由於 ID 令牌將透過 Service Worker 設置,並且 Service Worker 被限制從相同來源運行,因此不存在 CSRF 風險,因為嘗試呼叫您的端點的不同來源的網站將無法呼叫 Service Worker ,導致從伺服器的角度來看該請求似乎未經身份驗證。

雖然現在所有現代主要瀏覽器都支援 Service Worker,但一些較舊的瀏覽器不支援它們。因此,當 Service Worker 不可用或應用程式可能被限制為僅在支援 Service Worker 的瀏覽器上執行時,可能需要一些後備措施來將 ID 令牌傳遞到伺服器。

請注意,服務工作執行緒僅是單源的,並且僅安裝在透過 https 連線或 localhost 提供服務的網站上。

請造訪caniuse.com以了解有關 Service Worker 瀏覽器支援的更多資訊。