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

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

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

ภาพรวม

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

Web Modular API

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 เว็บเนมสเปซ

firebase.auth().currentUser.getIdToken()
  .then((idToken) => {
    // idToken can be passed back to server.
  })
  .catch((error) => {
    // Error occurred.
  });

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

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

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

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

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

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

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

Web Modular API

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 เว็บเนมสเปซ

// 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 Modular API

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 เว็บเนมสเปซ

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

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

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

หลังจากติดตั้งแล้ว โปรแกรมทำงานของบริการจะต้องเรียกใช้ clients.claim() เมื่อเปิดใช้งานเพื่อที่จะตั้งค่าเป็นตัวควบคุมสำหรับหน้าปัจจุบันได้

Web Modular API

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

API เว็บเนมสเปซ

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

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

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

Web Modular API

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

API เว็บเนมสเปซ

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

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

Web Modular API

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 เว็บเนมสเปซ

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

    // ...
}

บทสรุป

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

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

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

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