Quản lý phiên bằng trình chạy dịch vụ

Firebase Auth cung cấp khả năng sử dụng các worker dịch vụ để phát hiện và truyền mã thông báo nhận dạng Firebase để quản lý phiên. Điều này mang lại những lợi ích sau:

  • Khả năng truyền mã thông báo nhận dạng trên mọi yêu cầu HTTP từ máy chủ mà không cần thêm bất kỳ thao tác nào.
  • Có thể làm mới mã thông báo nhận dạng mà không cần thêm bất kỳ chuyến khứ hồi hoặc độ trễ nào.
  • Các phiên được đồng bộ hoá giữa phần phụ trợ và phần giao diện người dùng. Các ứng dụng cần truy cập vào các dịch vụ của Firebase như Cơ sở dữ liệu theo thời gian thực, Firestore, v.v. và một số tài nguyên phía máy chủ bên ngoài (cơ sở dữ liệu SQL, v.v.) có thể sử dụng giải pháp này. Ngoài ra, bạn cũng có thể truy cập vào cùng một phiên từ trình thực thi dịch vụ, trình thực thi web hoặc trình thực thi dùng chung.
  • Loại bỏ nhu cầu thêm mã nguồn Firebase Auth trên mỗi trang (giảm độ trễ). Trình chạy dịch vụ, được tải và khởi chạy một lần, sẽ xử lý việc quản lý phiên cho tất cả các ứng dụng ở chế độ nền.

Tổng quan

Firebase Auth được tối ưu hoá để chạy ở phía máy khách. Mã thông báo được lưu trong bộ nhớ web. Điều này cũng giúp bạn dễ dàng tích hợp với các dịch vụ khác của Firebase như Cơ sở dữ liệu theo thời gian thực, Cloud Firestore, Cloud Storage, v.v. Để quản lý các phiên từ phía máy chủ, bạn phải truy xuất và truyền mã thông báo nhận dạng đến máy chủ.

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

Tuy nhiên, điều này có nghĩa là một số tập lệnh phải chạy từ ứng dụng để lấy mã nhận dạng mới nhất, sau đó truyền mã này đến máy chủ thông qua tiêu đề yêu cầu, nội dung POST, v.v.

Điều này có thể không mở rộng quy mô và thay vào đó, bạn có thể cần cookie phiên phía máy chủ. Bạn có thể đặt mã thông báo nhận dạng làm cookie phiên nhưng các mã thông báo này có thời gian tồn tại ngắn và cần được làm mới từ máy khách, sau đó đặt làm cookie mới khi hết hạn. Việc này có thể yêu cầu thêm một chuyến khứ hồi nếu người dùng đã không truy cập vào trang web trong một thời gian.

Mặc dù Firebase Auth cung cấp một giải pháp quản lý phiên dựa trên cookie truyền thống hơn, nhưng giải pháp này hoạt động hiệu quả nhất đối với các ứng dụng dựa trên cookie httpOnly phía máy chủ và khó quản lý hơn vì mã thông báo phía máy chủ và mã thông báo phía máy khách có thể không đồng bộ, đặc biệt nếu bạn cũng cần sử dụng các dịch vụ Firebase khác dựa trên máy khách.

Thay vào đó, bạn có thể dùng các worker dịch vụ để quản lý phiên người dùng cho việc sử dụng phía máy chủ. Điều này có hiệu quả là do những yếu tố sau:

  • Service worker có quyền truy cập vào trạng thái Firebase Auth hiện tại. Bạn có thể truy xuất mã thông báo giá trị nhận dạng người dùng hiện tại từ trình chạy dịch vụ. Nếu mã thông báo đã hết hạn, SDK ứng dụng sẽ làm mới mã thông báo đó và trả về một mã thông báo mới.
  • Service worker có thể chặn các yêu cầu tìm nạp và sửa đổi các yêu cầu đó.

Thay đổi về trình chạy dịch vụ

Trình chạy dịch vụ sẽ cần có Thư viện Xác thực và khả năng lấy mã thông báo nhận dạng hiện tại nếu người dùng đã đăng nhập.

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

Tất cả các yêu cầu tìm nạp đến nguồn gốc của ứng dụng sẽ bị chặn và nếu có mã nhận dạng, mã này sẽ được thêm vào yêu cầu thông qua tiêu đề. Phía máy chủ, tiêu đề yêu cầu sẽ được kiểm tra mã thông báo nhận dạng, xác minh và xử lý. Trong tập lệnh trình chạy dịch vụ, yêu cầu tìm nạp sẽ bị chặn và sửa đổi.

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

Do đó, tất cả các yêu cầu đã xác thực sẽ luôn có mã nhận dạng được truyền trong tiêu đề mà không cần xử lý thêm.

Để phát hiện các thay đổi về trạng thái xác thực, trình chạy dịch vụ phải được cài đặt trên trang đăng nhập/đăng ký. Đảm bảo rằng service worker được gói để vẫn hoạt động sau khi trình duyệt đã đóng.

Sau khi cài đặt, trình chạy dịch vụ phải gọi clients.claim() khi kích hoạt để có thể thiết lập làm bộ điều khiển cho trang hiện tại.

Web

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

Web

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

Các thay đổi phía máy khách

Trình chạy dịch vụ (nếu được hỗ trợ) cần được cài đặt trên trang đăng nhập/đăng ký phía máy khách.

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

Khi người dùng đăng nhập và được chuyển hướng đến một trang khác, trình chạy dịch vụ sẽ có thể chèn mã thông báo nhận dạng vào tiêu đề trước khi quá trình chuyển hướng hoàn tất.

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

Các thay đổi phía máy chủ

Mã phía máy chủ sẽ có thể phát hiện mã thông báo nhận dạng trên mọi yêu cầu. Hành vi này được Admin SDK hỗ trợ cho Node.js hoặc bằng Web SDK bằng cách sử dụng FirebaseServerApp.

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 mô-đun trên web

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

    // ...
}

Kết luận

Ngoài ra, vì mã nhận dạng sẽ được đặt thông qua các trình chạy dịch vụ và các trình chạy dịch vụ chỉ được phép chạy từ cùng một nguồn gốc, nên không có nguy cơ xảy ra CSRF vì một trang web có nguồn gốc khác cố gắng gọi các điểm cuối của bạn sẽ không thể gọi trình chạy dịch vụ, khiến yêu cầu xuất hiện dưới dạng chưa được xác thực theo quan điểm của máy chủ.

Mặc dù hiện tại tất cả các trình duyệt chính hiện đại đều hỗ trợ worker dịch vụ, nhưng một số trình duyệt cũ hơn không hỗ trợ. Do đó, có thể cần một số phương án dự phòng để truyền mã nhận dạng (ID) cho máy chủ của bạn khi không có worker dịch vụ hoặc ứng dụng có thể bị hạn chế chỉ chạy trên những trình duyệt hỗ trợ worker dịch vụ.

Xin lưu ý rằng các trình chạy dịch vụ chỉ có một nguồn gốc và sẽ chỉ được cài đặt trên những trang web được phân phát thông qua kết nối HTTPS hoặc máy chủ cục bộ.

Tìm hiểu thêm về khả năng hỗ trợ trình chạy dịch vụ của trình duyệt tại caniuse.com.