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

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

  • Có thể truyền mã thông báo giá trị nhận dạng trên mọi yêu cầu HTTP từ máy chủ mà không cần thực hiện thêm thao tác nào.
  • Có thể làm mới mã thông báo giá trị nhận dạng mà không cần phải qua lại hay nhận thêm độ trễ.
  • Các phiên được đồng bộ hoá phụ trợ và 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. có thể sử dụng giải pháp này. Ngoài ra, cùng một phiên cũng có thể được truy cập từ trình chạy dịch vụ, trình chạy web hoặc trình chạy dùng chung.
  • Bạn không cần phải thêm mã nguồn Xác thực Firebase trên mỗi trang (giảm độ trễ). Service worker (đượ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 trong 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ớ trên 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, 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ý các phiên từ góc độ máy chủ, bạn phải truy xuất và chuyển mã thông báo mã 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ã thông báo mã nhận dạng mới nhất rồi 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 theo tỷ lệ. Thay vào đó, có thể sẽ cần đến cookie phiên phía máy chủ. Mã thông báo mã nhận dạng có thể được đặt làm cookie phiên nhưng những cookie này chỉ tồn tại trong thời gian ngắn và sẽ cần được làm mới từ ứng dụng, sau đó được đặ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 lượt 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ù tính năng Xác thực Firebase 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 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 ứng dụng và mã thông báo phía máy chủ có thể không đồng bộ hoá được, đặc biệt là khi bạn cũng cần sử dụng các dịch vụ Firebase khác dựa trên ứng dụng khách.

Thay vào đó, bạn có thể sử dụng trình chạy dịch vụ để quản lý các phiên của người dùng trong việc tiêu thụ phía máy chủ. Điều này 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 các yêu cầu đó.

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

Trình chạy dịch vụ sẽ 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.

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ả yêu cầu tìm nạp đến máy chủ gốc của ứng dụng sẽ bị chặn và nếu có mã thông báo mã nhận dạng, hãy 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 để tìm mã thông báo nhận dạng, sau đó 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ột mã thông báo giá trị nhận dạng được chuyển trong tiêu đề mà không cần xử lý thêm.

Để trình chạy dịch vụ phát hiện được các thay đổi 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ý. Hãy đảm bảo trình chạy dịch vụ được đi kèm để trình chạy này 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 dịch vụ làm đơn vị kiểm soát 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

Bạn cần cài đặt trình chạy dịch vụ này 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 mã 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 về 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. Hành vi này được SDK dành cho quản trị viên hỗ trợ cho Node.js hoặc với SDK web 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ó nguy cơ xảy ra 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ừ góc độ 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ũ không hỗ trợ trình thực thi dịch vụ này. Do đó, có thể cần một số phương án dự phòng để truyền mã thông báo mã nhận dạng đến máy chủ của bạn khi trình chạy dịch vụ không có sẵn hoặc ứ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 duy nhất 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ộ.

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