Firebase Auth предоставляет возможность использовать сервис-воркеров для обнаружения и передачи токенов Firebase ID для управления сеансами. Это обеспечивает следующие преимущества:
- Возможность передавать идентификационный токен при каждом HTTP-запросе от сервера без каких-либо дополнительных действий.
- Возможность обновления токена идентификатора без каких-либо дополнительных обходов или задержек.
- Синхронизированные сеансы серверной и внешней части. Приложения, которым необходим доступ к сервисам Firebase, таким как база данных реального времени, Firestore и т. д., а также к некоторым внешним ресурсам на стороне сервера (база данных SQL и т. д.), могут использовать это решение. Кроме того, к тому же сеансу можно получить доступ из сервис-воркера, веб-воркера или общего работника.
- Устраняет необходимость включать исходный код Firebase Auth на каждую страницу (уменьшает задержку). Сервис-воркер, загруженный и инициализированный один раз, будет обрабатывать управление сеансами для всех клиентов в фоновом режиме.
Обзор
Firebase Auth оптимизирован для работы на стороне клиента. Токены сохраняются в веб-хранилище. Это упрощает интеграцию с другими сервисами Firebase, такими как база данных реального времени, Cloud Firestore, облачное хранилище и т. д. Для управления сеансами с точки зрения сервера необходимо получить и передать на сервер идентификаторы-токены.
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.
Вместо этого сервис-воркеры могут использоваться для управления пользовательскими сеансами на стороне сервера. Это работает благодаря следующему:
- Сервисные работники имеют доступ к текущему состоянию аутентификации Firebase. Текущий токен идентификатора пользователя можно получить у работника службы. Если срок действия токена истек, клиентский SDK обновит его и вернет новый.
- Работники службы могут перехватывать запросы на выборку и изменять их.
Изменения в сервисном работнике
Сервисному работнику потребуется включить библиотеку аутентификации и возможность получать текущий токен идентификатора, если пользователь вошел в систему.
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); } }); }); };
Все запросы на выборку к источнику приложения будут перехвачены и, если токен идентификатора доступен, добавлен к запросу через заголовок. На стороне сервера заголовки запросов будут проверены на наличие идентификатора токена, проверены и обработаны. В сценарии сервисного работника запрос на выборку будет перехвачен и изменен.
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)); });
В результате все аутентифицированные запросы всегда будут иметь токен идентификатора, передаваемый в заголовке, без дополнительной обработки.
Чтобы сервис-воркер мог обнаружить изменения состояния аутентификации, его необходимо установить на странице входа/регистрации. Убедитесь, что сервис-воркер включен в комплект, чтобы он продолжал работать после закрытия браузера.
После установки работник службы должен вызвать 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: '/'}); }
Когда пользователь войдет в систему и будет перенаправлен на другую страницу, работник службы сможет внедрить токен идентификатора в заголовок до завершения перенаправления.
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('/'));
Веб-модульный 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');
}
// ...
}
Заключение
Кроме того, поскольку токены идентификатора будут установлены через сервис-воркеров, а сервис-воркеры могут запускаться из одного и того же источника, риск CSRF отсутствует, поскольку веб-сайт другого происхождения, пытающийся вызвать ваши конечные точки, не сможет вызвать сервис-воркера. , в результате чего запрос выглядит неаутентифицированным с точки зрения сервера.
Хотя сервис-воркеры теперь поддерживаются во всех современных основных браузерах, некоторые старые браузеры их не поддерживают. В результате может потребоваться некоторый резервный вариант для передачи токена идентификатора на ваш сервер, когда сервис-воркеры недоступны или запуск приложения может быть ограничен только в браузерах, поддерживающих сервис-воркеров.
Обратите внимание, что работники служб имеют только один источник и будут установлены только на веб-сайты, обслуживаемые через соединение https или localhost.
Узнайте больше о поддержке браузера для Service Worker на сайте caniuse.com .
Полезные ссылки
- Для получения дополнительной информации об использовании сервисных работников для управления сеансами ознакомьтесь с примером исходного кода приложения на GitHub .
- Развернутый пример приложения, описанного выше, доступен по адресу https://auth-service-worker.appspot.com.