การจัดการเซสชันด้วย Service Worker

Firebase Auth ให้คุณใช้ Service Worker เพื่อตรวจจับและส่ง โทเค็นรหัส Firebase สำหรับการจัดการเซสชัน ซึ่งมีประโยชน์ดังนี้

  • ความสามารถในการส่งผ่านโทเค็นรหัสในทุกคำขอ HTTP จากเซิร์ฟเวอร์โดยไม่มี งานเพิ่มเติม
  • ความสามารถในการรีเฟรชโทเค็นรหัสโดยไม่ต้องมีการส่งข้อมูลไป-กลับเพิ่มเติมหรือ เวลาในการตอบสนอง
  • เซสชันที่ซิงค์แบ็กเอนด์และฟรอนท์เอนด์ แอปพลิเคชันที่จำเป็นต้องเข้าถึง บริการ Firebase เช่น Realtime Database, Firestore เป็นต้น และบริการภายนอกบางรายการ ทรัพยากรฝั่งเซิร์ฟเวอร์ (ฐานข้อมูล SQL เป็นต้น) สามารถใช้โซลูชันนี้ได้ นอกจากนี้ ยังเข้าถึงเซสชันเดียวกันจาก Service Worker ได้ด้วย Web Worker หรือพนักงานที่ทำงานร่วมกัน
  • ขจัดความจำเป็นในการรวมซอร์สโค้ด Firebase Auth ในแต่ละหน้า (ลดเวลาในการตอบสนอง) Service Worker ซึ่งโหลดและเริ่มต้นครั้งเดียว จัดการเซสชันของลูกค้าทั้งหมดในพื้นหลัง

ภาพรวม

Firebase Auth ได้รับการเพิ่มประสิทธิภาพให้ทำงานในฝั่งไคลเอ็นต์ ระบบจะบันทึกโทเค็นไว้ใน พื้นที่เก็บข้อมูลเว็บ ซึ่งทำให้ง่ายต่อการผสานรวมกับบริการ Firebase อื่นๆ เช่น Realtime Database, Cloud Firestore, Cloud Storage ฯลฯ หากต้องการจัดการเซสชันจากมุมมองฝั่งเซิร์ฟเวอร์ โทเค็นรหัสต้องเป็น ที่ดึงออกมาและส่งผ่านไปยังเซิร์ฟเวอร์

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

อย่างไรก็ตาม นี่หมายความว่าสคริปต์บางรายการจะต้องเรียกใช้จากไคลเอ็นต์เพื่อรับ โทเค็นรหัสล่าสุด จากนั้นส่งไปยังเซิร์ฟเวอร์ผ่านส่วนหัวของคำขอ POST ร่างกาย ฯลฯ

การดำเนินการนี้อาจไม่ปรับขนาดและอาจต้องใช้คุกกี้ของเซสชันฝั่งเซิร์ฟเวอร์แทน โทเค็นรหัสสามารถตั้งให้เป็นคุกกี้ของเซสชันได้ แต่โทเค็นเหล่านี้มีอายุใช้งานน้อยและ จำเป็นต้องรีเฟรชจากไคลเอ็นต์แล้วตั้งค่าเป็นคุกกี้ใหม่เมื่อหมดอายุ ซึ่งอาจทำให้ต้องเดินทางไป-กลับเพิ่มเติมหากผู้ใช้ไม่ได้เข้าชม ไประยะหนึ่งแล้ว

ขณะที่ Firebase Auth ให้ประสบการณ์แบบเดิมมากกว่า โซลูชันการจัดการเซสชันที่อิงตามคุกกี้ โซลูชันนี้ทำงานได้ดีที่สุดสำหรับแอปพลิเคชันที่ใช้คุกกี้ httpOnly ฝั่งเซิร์ฟเวอร์ และยากต่อการจัดการเนื่องจากโทเค็นของไคลเอ็นต์และโทเค็นฝั่งเซิร์ฟเวอร์อาจได้รับ ไม่ซิงค์กัน โดยเฉพาะในกรณีที่คุณจำเป็นต้องใช้ Firebase ที่อิงตามไคลเอ็นต์อื่นๆ ด้วย บริการต่างๆ

จึงสามารถใช้ Service Worker เพื่อจัดการเซสชันผู้ใช้สำหรับฝั่งเซิร์ฟเวอร์แทนได้ การบริโภค วิธีนี้ได้ผลเนื่องจากสาเหตุต่อไปนี้

  • Service Worker มีสิทธิ์เข้าถึงสถานะการตรวจสอบสิทธิ์ Firebase ปัจจุบัน องค์ประกอบปัจจุบัน คุณเรียกดูโทเค็น User-ID ได้จาก Service Worker หากโทเค็นคือ SDK ของไคลเอ็นต์จะรีเฟรชและส่งคืน SDK ใหม่
  • Service Worker สกัดกั้นคำขอดึงข้อมูลและแก้ไขได้

การเปลี่ยนแปลง Service Worker

โปรแกรมทำงานของบริการจะต้องมีไลบรารีการตรวจสอบสิทธิ์และความสามารถในการ โทเค็นรหัสปัจจุบันหากผู้ใช้ลงชื่อเข้าใช้

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

ระบบจะดักจับคำขอดึงข้อมูลทั้งหมดที่ส่งไปยังต้นทางของแอปและหากโทเค็นรหัส ต่อท้ายคำขอผ่านทางส่วนหัว ฝั่งเซิร์ฟเวอร์ คำขอ ระบบจะตรวจสอบโทเค็นรหัส รวมทั้งยืนยันและประมวลผลส่วนหัว ในสคริปต์โปรแกรมทำงานของบริการ คำขอดึงข้อมูลจะถูกดักข้อมูล และ แก้ไขแล้ว

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

ดังนั้น คำขอที่ตรวจสอบสิทธิ์แล้วทั้งหมดจะมีโทเค็นรหัสส่งเข้ามาเสมอ ส่วนหัวโดยไม่ต้องประมวลผลเพิ่มเติม

โปรแกรมทำงานของบริการจะต้องตรวจหาการเปลี่ยนแปลงสถานะการตรวจสอบสิทธิ์ ติดตั้งในหน้าลงชื่อเข้าใช้/ลงชื่อสมัครใช้ โปรดตรวจสอบว่า มีการรวม Service Worker ไว้เพื่อให้ยังคงทำงานได้หลังจากเบราว์เซอร์ ปิดแล้ว

หลังจากติดตั้ง บริการ ผู้ปฏิบัติงานต้องเรียกใช้ clients.claim() เมื่อเปิดใช้งาน จึงจะตั้งค่าเป็น สำหรับหน้าปัจจุบัน

Web

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

Web

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

การเปลี่ยนแปลงฝั่งไคลเอ็นต์

ต้องติดตั้ง Service Worker ในฝั่งไคลเอ็นต์ หากรองรับ หน้าลงชื่อเข้าใช้/ลงชื่อสมัครใช้

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

เมื่อผู้ใช้ลงชื่อเข้าใช้และเปลี่ยนเส้นทางไปยังหน้าอื่น โปรแกรมทำงานของบริการ สามารถแทรกโทเค็นรหัสในส่วนหัวได้ก่อนที่การเปลี่ยนเส้นทางจะเสร็จสมบูรณ์

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

การเปลี่ยนแปลงฝั่งเซิร์ฟเวอร์

รหัสฝั่งเซิร์ฟเวอร์จะสามารถตรวจหาโทเค็นรหัสได้ในทุกคำขอ ช่วงเวลานี้ พฤติกรรมที่สนับสนุนโดย Admin SDK สำหรับ Node.js หรือในเว็บ SDK ที่ใช้ 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('/'));

Web Modular API

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

    // ...
}

บทสรุป

นอกจากนี้ เนื่องจากโทเค็น ID จะตั้งค่าผ่าน Service Worker และบริการ ถูกจำกัดให้ผู้ปฏิบัติงานทำงานจากต้นทางเดียวกัน จึงไม่มีความเสี่ยงที่จะเกิด CSRF เนื่องจากเว็บไซต์ต้นทางต่างๆ ที่พยายามเรียกปลายทางของคุณจะ เรียกใช้ Service Worker ไม่สำเร็จ ซึ่งทำให้คำขอปรากฏขึ้น ไม่ผ่านการตรวจสอบสิทธิ์จากมุมมองของเซิร์ฟเวอร์

แม้ว่าขณะนี้โปรแกรมทำงานของบริการ (Service Worker) จะได้รับการรองรับในเบราว์เซอร์หลักๆ ทั้งหมด แต่บางเบราว์เซอร์ เบราว์เซอร์รุ่นเก่าไม่รองรับ ดังนั้น วิดีโอสำรองบางส่วนจึงอาจ ในการส่งโทเค็นรหัสไปยังเซิร์ฟเวอร์ของคุณเมื่อโปรแกรมทำงานของบริการไม่ได้ หรือจำกัดให้แอปทำงานเฉพาะในเบราว์เซอร์ที่รองรับ Service Worker

โปรดทราบว่าโปรแกรมทำงานของบริการจะมาจากต้นทางเดียวเท่านั้นและจะได้รับการติดตั้งเท่านั้น ในเว็บไซต์ที่ให้บริการผ่านการเชื่อมต่อ HTTPS หรือ localhost

ดูข้อมูลเพิ่มเติมเกี่ยวกับการรองรับเบราว์เซอร์สำหรับ Service Worker ได้ที่ caniuse.com