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 ฯลฯ หากต้องการจัดการเซสชันจากมุมมองฝั่งเซิร์ฟเวอร์ โทเค็นรหัสต้องเป็น ที่ดึงออกมาและส่งผ่านไปยังเซิร์ฟเวอร์
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. });
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
โปรแกรมทำงานของบริการจะต้องมีไลบรารีการตรวจสอบสิทธิ์และความสามารถในการ โทเค็นรหัสปัจจุบันหากผู้ใช้ลงชื่อเข้าใช้
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); } }); }); };
// 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); } }); }); };
ระบบจะดักจับคำขอดึงข้อมูลทั้งหมดที่ส่งไปยังต้นทางของแอปและหากโทเค็นรหัส ต่อท้ายคำขอผ่านทางส่วนหัว ฝั่งเซิร์ฟเวอร์ คำขอ ระบบจะตรวจสอบโทเค็นรหัส รวมทั้งยืนยันและประมวลผลส่วนหัว ในสคริปต์โปรแกรมทำงานของบริการ คำขอดึงข้อมูลจะถูกดักข้อมูล และ แก้ไขแล้ว
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)); });
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()
เมื่อเปิดใช้งาน จึงจะตั้งค่าเป็น
สำหรับหน้าปัจจุบัน
self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); });
self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); });
การเปลี่ยนแปลงฝั่งไคลเอ็นต์
ต้องติดตั้ง Service Worker ในฝั่งไคลเอ็นต์ หากรองรับ หน้าลงชื่อเข้าใช้/ลงชื่อสมัครใช้
// Install servicerWorker if supported on sign-in/sign-up page. if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js', {scope: '/'}); }
// Install servicerWorker if supported on sign-in/sign-up page. if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js', {scope: '/'}); }
เมื่อผู้ใช้ลงชื่อเข้าใช้และเปลี่ยนเส้นทางไปยังหน้าอื่น โปรแกรมทำงานของบริการ สามารถแทรกโทเค็นรหัสในส่วนหัวได้ก่อนที่การเปลี่ยนเส้นทางจะเสร็จสมบูรณ์
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. });
// 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
// 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('/'));
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
ลิงก์ที่มีประโยชน์
- ดูข้อมูลเพิ่มเติมเกี่ยวกับการใช้ Service Worker สำหรับการจัดการเซสชันได้ที่ ออก ตัวอย่างซอร์สโค้ดของแอปบน GitHub
- ตัวอย่างแอปด้านบนที่ทำให้ใช้งานได้แล้วจะอยู่ที่ https://auth-service-worker.appspot.com