使用 Service Worker 进行会话管理

Firebase 身份验证支持使用服务工作器检测和传递 Firebase ID 令牌以实现会话管理。这样做具有以下优势:

  • 能够在发送到服务器的每个 HTTP 请求中传递 ID 令牌,而无需执行任何额外的操作。
  • 能够刷新 ID 令牌,而无需任何额外的往返调用,也不会造成延迟。
  • 后端和前端的会话同步。需要访问 Firebase 服务(例如 Realtime Database、Firestore 等)和某些外部服务器端资源(SQL 数据库等)的应用可以使用此解决方案。此外,通过 Service Worker、Web Worker 或 Shared Worker 也能访问同一会话。
  • 无需在每个页面上包含 Firebase Authentication 源代码(可减少延迟)。Service Worker 只需加载并初始化一次,便可在后台处理所有客户端的会话管理。

概览

Firebase Authentication 专为在客户端运行而进行了优化。令牌保存在 Web 存储服务中。这还可让您轻松地与其他 Firebase 服务(例如 Realtime Database、Cloud Firestore、Cloud Storage 等)集成。如需从服务器端的角度管理会话,必须检索 ID 令牌并将其传递到服务器。

Web 模块化 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 命名空间型 API

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

但是,这意味着某些脚本必须从客户端运行,以获取最新的 ID 令牌,然后通过请求标头、POST 正文等将其传递给服务器。

这种方式可能无法大规模运用,因而或许需要使用服务器端会话 Cookie。ID 令牌可以设置为会话 Cookie,但这些 Cookie 只在短时间内有效,到期时需要从客户端刷新,然后设置为新 Cookie(如果用户一段时间内没有访问网站,就可能需要一次额外的往返调用)。

虽然 Firebase Authentication 提供了更传统的基于 Cookie 的会话管理解决方案,但此解决方案最适合服务器端基于 httpOnly Cookie 的应用且较难管理,因为客户端令牌和服务器端令牌可能无法保持同步,尤其是在您还需要使用其他基于客户端的 Firebase 服务时。

作为替代方法,可以利用 Service Worker 来管理用户会话以供服务器端使用。此方法有效的原因如下:

  • Service Worker 可以访问当前的 Firebase Authentication 状态。可以从 Service Worker 检索当前用户 ID 令牌。如果此令牌过期,客户端 SDK 将刷新此令牌并返回一个新令牌。
  • Service Worker 可以拦截提取请求并加以修改。

Service Worker 的变化

Service Worker 将需要包含 Authentication 库,以及能够获取当前 ID 令牌(如果用户已登录)。

Web 模块化 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 命名空间型 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 令牌会通过标头附加到相应请求。系统将在服务器端检查请求标头中是否有 ID 令牌,并对其进行验证和处理。在 Service Worker 脚本中将会拦截并修改提取请求。

Web 模块化 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 命名空间型 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 能够检测到 Authentication 状态的变化,通常必须在登录/注册页面安装它。安装后,Service Worker 必须在激活时调用 clients.claim(),以便可以设置为当前页面的控制器。

Web 模块化 API

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

Web 命名空间型 API

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

客户端的变化

需要在客户端登录/注册页面上安装 Service Worker(如果其受支持)。

Web 模块化 API

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

Web 命名空间型 API

// 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 模块化 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 命名空间型 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 时,可能需要通过某种后备方式将 ID 令牌传递到服务器,或者将应用限制为仅在支持 Service Worker 的浏览器上运行。

请注意,Service Worker 仅支持单一来源,并且仅会在通过 https 连接或 localhost 提供服务的网站上加以安装。

如需详细了解浏览器对 Service Worker 的支持,请访问 caniuse.com