透過服務工作站管理工作階段

Firebase Auth 可讓您使用服務工作程式,偵測及傳遞 Firebase ID 權杖,用於工作階段管理。這麼做有以下好處:

  • 能夠在伺服器的每個 HTTP 要求中傳遞 ID 權杖,而無需額外操作。
  • 能夠在不增加往返時間或延遲的情況下,重新整理 ID 權杖。
  • 後端和前端同步工作階段。需要存取 Firebase 服務 (例如即時資料庫、Firestore 等) 和某些外部伺服器端資源 (SQL 資料庫等) 的應用程式,可以使用這項解決方案。此外,您也可以透過服務工作者、Web Worker 或共用工作者存取相同的工作階段。
  • 無須在每個網頁中加入 Firebase Auth 原始碼 (可降低延遲時間)。載入及初始化一次的服務工作站會在背景處理所有用戶端的工作階段管理作業。

總覽

Firebase Auth 已針對在用戶端執行的情況進行最佳化。權杖會儲存在網路儲存空間中。這樣一來,您就能輕鬆整合其他 Firebase 服務,例如 Realtime Database、Cloud Firestore、Cloud Storage 等。如要從伺服器端管理工作階段,就必須擷取 ID 權杖並傳送至伺服器。

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

不過,這表示必須透過用戶端執行某些指令碼,才能取得最新的 ID 權杖,然後透過要求標頭、POST 主體等傳遞至伺服器。

這可能無法擴充,因此可能需要伺服器端工作階段 Cookie。ID 權杖可設為工作階段 Cookie,但這些權杖的生命週期很短,需要從用戶端重新整理,然後在到期時設為新的 Cookie,如果使用者已一段時間未造訪網站,可能需要額外的來回傳輸。

雖然 Firebase Auth 提供較傳統的以 Cookie 為基礎的會話管理解決方案,但這項解決方案最適合用於以 Cookie 為基礎的伺服器端 httpOnly 應用程式,且由於用戶端權杖和伺服器端權杖可能會不同步,因此較難管理,特別是如果您也需要使用其他以用戶端為基礎的 Firebase 服務。

相反地,服務工作者可用於管理伺服器端使用者的使用者工作階段。這項功能可運作的原因如下:

  • 服務工作者可存取目前的 Firebase Auth 狀態。您可以從服務工作者擷取目前的使用者 ID 權杖。如果權杖已到期,用戶端 SDK 會重新整理權杖並傳回新的權杖。
  • 服務工作站可以攔截擷取要求並加以修改。

Service worker 異動

服務工作者必須包含驗證程式庫,並在使用者登入時取得目前的 ID 權杖。

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

系統會攔截所有應用程式來源的擷取要求,並在可用的 ID 權杖中,透過標頭附加至要求。伺服器端會檢查要求標頭中的 ID 權杖,並進行驗證及處理。在服務工作者指令碼中,系統會攔截並修改擷取要求。

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

因此,所有經過驗證的要求都會在標頭中傳遞 ID 權杖,而不需要額外處理。

為了讓服務工作者偵測到驗證狀態變更,您必須在登入/註冊頁面上安裝服務工作者。請確認服務工作站已綁定,這樣在關閉瀏覽器後,服務工作站仍可正常運作。

安裝完成後,服務 worker 必須在啟用時呼叫 clients.claim(),才能將其設為目前網頁的控制器。

Web

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

Web

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

用戶端變更

如果支援 Service Worker,就必須在用戶端登入/註冊頁面上安裝。

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

當使用者登入並重新導向至其他頁面時,Service Worker 會在重新導向完成前,在標頭中插入 ID 權杖。

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

伺服器端變更

伺服器端程式碼可在每項要求中偵測 ID 權杖。Node.js 專用的 Admin SDK 或使用 FirebaseServerApp 的 Web SDK 都支援這項行為。

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 模組化 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');
    }

    // ...
}

結論

此外,由於 ID 權杖會透過服務工作者設定,且服務工作者會受到限制,只能從相同來源執行,因此不會有 CSRF 風險,因為來自不同來源的網站嘗試呼叫端點時,會無法叫用服務工作者,導致要求在伺服器的角度看來未經過驗證。

雖然所有新型主流瀏覽器都支援服務工作者,但部分舊版瀏覽器不支援這項功能。因此,如果服務工作者無法使用,或是應用程式只能在支援服務工作者的瀏覽器上執行,就可能需要一些備用機制來將 ID 權杖傳遞至伺服器。

請注意,Service Worker 只能單一來源,且只能安裝在透過 https 連線或 localhost 提供服務的網站上。

如要進一步瞭解瀏覽器支援 Service Worker,請前往 caniuse.com