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

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

  • Có thể truyền mã thông báo mã nhận dạng cho mọi yêu cầu HTTP từ máy chủ mà không cần làm gì thêm.
  • Có thể làm mới mã thông báo mã nhận dạng mà không có thêm bất kỳ lượt trọn vòng hoặc độ trễ nào khác.
  • Các phiên được đồng bộ hoá với phần phụ trợ và giao diện người dùng. Những ứ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 cùng một phiên từ trình chạy dịch vụ, trình chạy web hoặc trình thực thi dùng chung.
  • Bạn không cần phải đưa mã nguồn Xác thực Firebase 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ả ứng dụng ở chế độ nền.

Tổng quan

Tính năng Xác thực Firebase đượ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. Nhờ vậy, bạn cũng có thể dễ dàng tích hợp với các dịch vụ khác của Firebase, chẳng hạn như Cơ sở dữ liệu theo thời gian thực, Cloud Firestore, Cloud Storage, v.v. Để quản lý phiên hoạt động từ phía máy chủ, bạn phải truy xuất và chuyển mã thông báo mã nhận dạng cho máy chủ.

API mô-đun 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.
  });

API được đặt tên trong vùng chứa tên của 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 khách để nhận mã thông báo mã nhận dạng mới nhất, sau đó truyền mã đó đến máy chủ thông qua tiêu đề yêu cầu, nội dung POST, v.v.

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

Mặc dù phương thức Xác thực Firebase cung cấp 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 phù hợp nhất 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 máy khách và mã thông báo phía máy chủ có thể không đồng bộ, đặc biệt là khi bạn cũng cần sử dụng các dịch vụ Firebase dựa trên ứng dụng khác.

Thay vào đó, bạn có thể sử dụng trình chạy dịch vụ để quản lý phiên hoạt động của người dùng nhằm sử dụng phía máy chủ. Tính năng này có hiệu quả vì những lý do sau:

  • Trình chạy dịch vụ có quyền truy cập vào trạng thái Xác thực Firebase hiện tại. Bạn có thể truy xuất mã thông báo mã 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ã đó và trả về một mã mới.
  • Trình chạy dịch vụ có thể chặn các yêu cầu tìm nạp và sửa đổi chúng.

Thay đổi của trình chạy dịch vụ

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

API mô-đun 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);
      }
    });
  });
};

API được đặt tên trong vùng chứa tên của 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ã thông báo mã, thì hệ thống sẽ nối thêm vào yêu cầu thông qua tiêu đề. Phía máy chủ, các tiêu đề của yêu cầu sẽ được kiểm tra để tìm mã thông báo mã nhận dạng, sau đó đã được 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.

API mô-đun 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));
});

API được đặt tên trong vùng chứa tên của 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ột mã thông báo mã nhận dạng được chuyển vào tiêu đề mà không cần xử lý thêm.

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

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 trình này làm bộ điều khiển cho trang hiện tại.

API mô-đun web

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

API được đặt tên trong vùng chứa tên của web

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

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.

API mô-đun web

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

API được đặt tên trong vùng chứa tên của 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 mã nhận dạng vào tiêu đề trước khi quá trình chuyển hướng hoàn tất.

API mô-đun 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.
  });

API được đặt tên trong vùng chứa tên của 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.
  });

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 trong mọi yêu cầu. SDK dành cho quản trị viên cho Node.js hoặc SDK web hỗ trợ hành vi này 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 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ã thông báo mã nhận dạng sẽ được đặt thông qua trình chạy dịch vụ và trình chạy dịch vụ bị hạn chế chạy từ cùng một nguồn gốc, nên sẽ không có rủi ro về CSRF vì một trang web có nguồn gốc khác cố gắng gọi điểm cuối của bạn sẽ không gọi được trình chạy dịch vụ, khiến yêu cầu có vẻ như chưa được xác thực từ phía máy chủ.

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

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

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