Firebase Auth מאפשר להשתמש ב-service workers כדי לזהות ולהעביר אסימונים מזהים של Firebase לניהול סשנים. היתרונות של השימוש בשיטה הזו:
- אפשרות להעביר אסימון מזהה בכל בקשת HTTP מהשרת בלי לבצע פעולות נוספות.
- אפשרות לרענן את אסימון המזהה בלי לבצע עוד הלוך ושוב או בלי להוסיף זמן אחזור.
- סשנים מסונכרנים של קצה עורפי וקצה חזיתי. אפשר להשתמש בפתרון הזה באפליקציות שצריכות לגשת לשירותי Firebase כמו Realtime Database, Firestore וכו' ולמשאבים מסוימים בצד השרת (מסד נתונים SQL וכו'). בנוסף, אפשר לגשת לאותו סשן גם מ-service worker, מ-web worker או מ-shared worker.
- אין יותר צורך לכלול את קוד המקור של Firebase Auth בכל דף (מה שמקטין את זמן האחזור). ה-service worker, שנטען ועבר אתחול פעם אחת, יטפל בניהול הסשן ברקע עבור כל הלקוחות.
סקירה כללית
האימות ב-Firebase מותאם לפעולה בצד הלקוח. הטוקנים נשמרים באחסון האינטרנט. כך קל לשלב גם שירותים אחרים של 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 וכו'.
יכול להיות שהשיטה הזו לא תתאים לכל האתרים, ובמקום זאת צריך להשתמש בקובצי Cookie של סשן בצד השרת. אפשר להגדיר טוקנים של מזהים כקובצי Cookie זמניים, אבל הם תקפים לזמן קצר בלבד. כדי להשתמש בהם שוב, צריך לרענן אותם מהלקוח ואז להגדיר אותם כקובצי Cookie חדשים כשתוקף שלהם פג. יכול להיות שיידרש סיבוב נוסף של בקשות אם המשתמש לא ביקר באתר במשך זמן מה.
Firebase Auth מספק פתרון מסורתי יותר לניהול סשנים שמבוסס על קובצי Cookie. הפתרון הזה מתאים במיוחד לאפליקציות שמבוססות על קובצי Cookie בצד השרת httpOnly, אבל קשה יותר לנהל אותו כי יכול להיות שיהיה חוסר סנכרון בין הטוקנים של הלקוח לבין הטוקנים בצד השרת, במיוחד אם צריך להשתמש גם בשירותי Firebase אחרים שמבוססים על לקוח.
במקום זאת, אפשר להשתמש ב-service workers כדי לנהל סשנים של משתמשים לשימוש בצד השרת. הסיבות לכך הן:
- ל-Service Workers יש גישה למצב הנוכחי של Firebase Auth. אפשר לאחזר את אסימון המזהה של המשתמש הנוכחי מ-Service Worker. אם פג התוקף של האסימון, ערכת ה-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); } }); }); };
כל בקשות האחזור למקור האפליקציה ייורטו, ואם יש טוקן מזהה, הוא יצורף לבקשה דרך הכותרת. בצד השרת, כותרות הבקשה ייבדקו כדי לוודא שהן מכילות את טוקן ה-ID, יאומתו ויעברו עיבוד. בסקריפט של קובץ השירות (service worker), בקשת האחזור תעבור יירוט ושינוי.
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 workers, ו-service workers מוגבלים להרצה מאותו מקור, אין סיכון ל-CSRF, כי אתר ממקור אחר שמנסה לקרוא לנקודות הקצה שלכם לא יצליח להפעיל את ה-service worker, ולכן הבקשה תופיע כבקשה לא מאומתת מנקודת המבט של השרת.
למרות ש-service workers נתמכים עכשיו בכל הדפדפנים המודרניים העיקריים, יש דפדפנים ישנים שלא תומכים בהם. כתוצאה מכך, יכול להיות שיהיה צורך בפתרון חלופי כדי להעביר את האסימון המזהה לשרת שלכם כש-Service Workers לא זמינים, או שאפשר להגביל את האפליקציה כך שתפעל רק בדפדפנים שתומכים ב-Service Workers.
חשוב לדעת שקובצי service worker הם ממקור יחיד בלבד, והם יותקנו רק באתרים שמוצגים דרך חיבור https או localhost.
מידע נוסף על תמיכה בדפדפן עבור קובצי שירות זמין באתר caniuse.com.
קישורים שימושיים
- מידע נוסף על שימוש ב-service workers לניהול סשנים זמין בקוד המקור של האפליקציה לדוגמה ב-GitHub.
- אפליקציית דוגמה שכוללת את ההטמעה שלמעלה זמינה בכתובת https://auth-service-worker.appspot.com