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

Firebase Auth 能讓您透過 Service Worker 偵測並傳遞 用於管理工作階段的 Firebase ID 權杖。這麼做有以下好處:

  • 能從伺服器的每個 HTTP 要求中傳遞 ID 權杖,但沒有 其他作業
  • 可重新整理 ID 符記,無需額外來回或 延遲時間較短
  • 後端和前端同步處理工作階段。需要存取資料的應用程式 Firebase 服務,例如即時資料庫、Firestore 等 伺服器端資源 (SQL 資料庫等) 可以使用這項解決方案。 此外,您也能從 Service Worker 存取相同工作階段。 或共用工作站。
  • 不必在每個網頁上加入 Firebase 驗證原始碼 (縮短延遲時間)。Service Worker 會載入並初始化一次 在背景中為所有用戶端處理工作階段管理。

總覽

Firebase Auth 經過最佳化,可在用戶端執行。權杖儲存位置 網路儲存空間就能輕鬆與其他 Firebase 服務整合 例如即時資料庫、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 驗證 以 Cookie 為基礎的工作階段管理解決方案 這項解決方案最適合伺服器端 httpOnly Cookie 型應用程式 也較難管理 不同步,尤其是需同時使用其他用戶端式 Firebase 時 免費 Google Cloud 服務

相反地,服務工作處理程序可用於管理伺服器端的使用者工作階段 提高用量上限系統提供這個功能的原因如下:

  • Service Worker 可存取目前的 Firebase 驗證狀態。目前 可從 Service Worker 擷取使用者 ID 權杖。如果權杖是 則用戶端 SDK 會重新整理並傳回新的。
  • 服務工作處理程序可以攔截及修改擷取要求。

Service Worker 異動

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 權杖是否經過驗證和處理。 在 Service Worker 指令碼中,系統會攔截擷取要求, 已修改。

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 符記傳入 無須額外處理即可

為了讓 Service Worker 偵測驗證狀態變更,必須 安裝在登入/註冊網頁上。請確認 Service 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 的 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('/'));

網頁模組 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 權杖是透過 Service Worker 和 Service 設定 工作站只能從相同的來源執行,沒有 CSRF 的風險 由於嘗試呼叫端點的不同來源網站將會 無法叫用 Service Worker,導致要求 設為未經驗證

雖然目前所有現代主要瀏覽器都支援 Service Worker,但 不支援所有舊版瀏覽器因此,系統可能會 在服務工作處理程序並未的情況下,將 ID 權杖傳遞至伺服器 或是使用應用程式,只能在支援 Service Worker。

請注意,Service Worker 只有單一來源,因此只會安裝 透過 https 連線或 localhost 提供的網站上。

如要進一步瞭解 Service Worker 的瀏覽器支援,請參閱: caniuse.com