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ã Firebase để quản lý phiên. Điều này mang lại các lợi ích sau:

  • Khả năng chuyển mã thông báo ID trên mọi yêu cầu HTTP từ máy chủ mà không cần công việc bổ sung.
  • Có thể làm mới mã thông báo nhận dạng mà không cần phải đi khứ hồi hoặc độ 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 các dịch vụ của Firebase như Cơ sở dữ liệu theo thời gian thực, Firestore, v.v. tài nguyên phía máy chủ (cơ sở dữ liệu SQL, 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ụ, nhân viên web hoặc trình thực thi dùng chung.
  • Bạn không cần 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ý quản lý phiên cho tất cả máy khách 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. Đã lưu mã thông báo vào bộ nhớ trên web. Nhờ đó, bạn cũng có thể dễ dàng tích hợp với các dịch vụ Firebase khác 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ủ, mã thông báo mã nhận dạng phải được truy xuất và truyền đế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 để nhận được mã thông báo mã nhận dạng mới nhất rồi chuyển mã đó đến máy chủ thông qua tiêu đề của yêu cầu, POST nội dung, 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ủ. Bạn có thể đặt mã thông báo mã nhận dạng làm cookie phiên, nhưng các mã này chỉ tồn tại trong thời gian ngắn và sẽ cần được làm mới trên máy khách rồi đặt làm cookie mới khi hết hạn có thể yêu cầu thêm một lượt khứ hồi nếu người dùng chưa truy cập vào trong một thời gian.

Mặc dù tính năng Xác thực Firebase cung cấp tính năng giải pháp quản lý phiên dựa trên cookie, giải pháp này phù hợp nhất cho 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ể bị lấy không đồng bộ, đặc biệt là khi bạn cũng cần sử dụng Firebase dựa trên ứng dụng khác luôn miễn phí.

Thay vào đó, trình chạy dịch vụ có thể được sử dụng để quản lý phiên người dùng ở phía máy chủ người dùng. Đ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. Dòng điện mã thông báo mã nhận dạng người dùng có thể được truy xuất từ trình chạy dịch vụ. Nếu mã thông báo là hết hạn, thì SDK ứng dụng khách sẽ làm mới SDK đó và trả về một SDK 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 tải 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);
      }
    });
  });
};

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

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

Sau khi cài đặt, dịch vụ worker 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

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

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ụ có thể chèn mã thông báo ID 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. Chiến dịch này hành vi được SDK dành cho quản trị viên cho Node.js hoặc web hỗ trợ SDK bằ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 ID sẽ được thiết lập thông qua trình chạy dịch vụ và dịch vụ các worker bị hạn chế 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 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ác trình duyệt cũ hơn không hỗ trợ các định dạng này. Do đó, một số phương án dự phòng có thể cần để truyền mã thông báo ID đến máy chủ của bạn khi trình chạy dịch vụ không 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 duy nhất và sẽ chỉ được cài đặt trên các trang web được phân phối qua kết nối https hoặc localhost.

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