Firebase Auth 提供一項功能,可讓 Service Worker 偵測及傳遞 Firebase ID 權杖以進行工作階段管理。這麼做有以下好處:
- 可以在來自伺服器的每個 HTTP 要求中傳遞 ID 權杖,不需要任何其他操作。
- 可重新整理 ID 權杖,不需要任何其他來回或延遲時間。
- 後端和前端同步工作階段。需要存取 Firebase 服務 (例如即時資料庫、Firestore 等) 的應用程式和一些外部伺服器端資源 (SQL 資料庫等) 的應用程式,才能使用這項解決方案。此外,您也可以透過 Service Worker、網路工作站或共用工作站存取同一個工作階段。
- 省去在每個頁面加入 Firebase 驗證原始碼的麻煩 (可縮短延遲時間)。先前載入並初始化的 Service Worker 會處理背景所有用戶端的工作階段管理。
總覽
Firebase 驗證已經過最佳化,適合在用戶端上執行。權杖會儲存在網路儲存空間中。這可讓您輕鬆整合即時資料庫、Cloud Firestore、Cloud Storage 等其他 Firebase 服務。如要從伺服器端管理工作階段,您必須擷取 ID 權杖並傳遞至伺服器。
網路模組 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. });
不過,這表示部分指令碼必須從用戶端執行,才能取得最新的 ID 權杖,然後透過要求標頭、POST 主體等將權杖傳遞至伺服器。
這項功能可能無法擴充,可能需要改用伺服器端工作階段 Cookie。ID 權杖可設為工作階段 Cookie,但這些權杖暫時存在,需要從用戶端重新整理,然後在到期時將其設為新的 Cookie,因此如果使用者已有一段時間未造訪網站,可能需要進行額外的來回行程。
雖然 Firebase 驗證提供更傳統的以 Cookie 為基礎的工作階段管理解決方案,但這個解決方案最適合伺服器端 httpOnly
Cookie 型應用程式。由於用戶端權杖和伺服器端權杖可能無法同步,因此更難管理,尤其是如果您還需要使用其他以用戶端為基礎的 Firebase 服務。
而是可以改由服務工作站管理伺服器端耗用的使用者工作階段。這項功能的運作原理如下:
- Service Worker 可存取目前的 Firebase 驗證狀態。您可以從 Service Worker 擷取目前的使用者 ID 權杖。如果權杖過期,用戶端 SDK 會重新整理權杖,並傳回新權杖。
- 服務工作處理程序可以攔截並修改擷取要求。
Service Worker 變更
Service Worker 將需要包含驗證程式庫,以及若使用者已登入,取得目前 ID 權杖的功能。
網路模組 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); } }); }); };
系統會攔截所有傳送至應用程式來源的擷取要求;如果有 ID 權杖,則系統會透過標頭附加至要求。伺服器端會檢查要求標頭,確認 ID 權杖並經過驗證及處理。在 Service Worker 指令碼中,擷取要求會遭到攔截及修改。
網路模組 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)); });
因此,所有通過驗證的要求一律會在標頭中傳遞 ID 權杖,不需額外處理。
為了讓服務工作站偵測驗證狀態變更,服務工作站必須安裝在登入/註冊頁面上。請確定 Service Worker 已組合,以便在瀏覽器關閉後仍可運作。
安裝後,服務工作站必須在啟用時呼叫 clients.claim()
,才能將該工作設為目前頁面的控制器。
網路模組 API
self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); });
網路命名空間 API
self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); });
用戶端變更
用戶端登入/註冊頁面中必須安裝 Service Worker (如果支援的話)。
網路模組 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: '/'}); }
當使用者登入並重新導向至其他頁面時,服務工作站將可在重新導向完成前,在標頭中插入 ID 權杖。
網路模組 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. });
伺服器端變更
伺服器端程式碼將能偵測每個請求的 ID 權杖。Node.js 適用的 Admin SDK 或使用 FirebaseServerApp
的 Web SDK 支援這個行為。
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('/'));
網路模組 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,導致要求顯示為未經驗證的要求。
雖然目前所有新式主要瀏覽器皆支援 Service Worker,但部分舊版瀏覽器並不支援。因此,當服務工作站無法使用時,您可能需要備用項將 ID 權杖傳送至伺服器,或者讓應用程式限制只能在支援服務工作站的瀏覽器中執行。
請注意,服務工作站只會安裝單一來源,而且只會安裝在透過 https 連線或 localhost 服務的網站上。
如要進一步瞭解 Service Worker 的瀏覽器支援,請前往 caniuse.com。
實用連結
- 如要進一步瞭解如何使用 Service Worker 管理工作階段,請參閱 GitHub 上的範例應用程式原始碼。
- 上述已部署的範例應用程式可在 https://auth-service-worker.appspot.com 取得