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

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

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

ภาพรวม

Firebase Auth ได้รับการเพิ่มประสิทธิภาพให้ทำงานในฝั่งไคลเอ็นต์ ระบบจะบันทึกโทเค็นไว้ใน ที่เก็บข้อมูลบนเว็บ ซึ่งจะช่วยให้ผสานรวมกับบริการอื่นๆ ของ Firebase ได้ง่ายขึ้นด้วย เช่น ฐานข้อมูลเรียลไทม์, 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 Auth ปัจจุบัน คุณสามารถเรียกโทเค็นรหัสผู้ใช้ปัจจุบันได้จาก Service Worker หากโทเค็นหมดอายุ SDK ไคลเอ็นต์จะรีเฟรชโทเค็นและแสดงโทเค็นใหม่
  • Service Worker สามารถสกัดกั้นคำขอ Fetch และแก้ไขได้

การเปลี่ยนแปลง 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);
      }
    });
  });
};

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

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

หลังจากติดตั้งแล้ว 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: '/'});
}

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

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 หรือ Web 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');
    }

    // ...
}

บทสรุป

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

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

โปรดทราบว่า Service Worker จะมีต้นทางเดียวเท่านั้นและจะติดตั้งได้เฉพาะในเว็บไซต์ที่แสดงผ่านการเชื่อมต่อ https หรือ localhost

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