Управление сеансами с работниками службы

Firebase Auth предоставляет возможность использовать сервис-воркеры для обнаружения и передачи токенов Firebase ID для управления сессиями. Это обеспечивает следующие преимущества:

  • Возможность передавать идентификационный токен в каждом HTTP-запросе с сервера без каких-либо дополнительных действий.
  • Возможность обновления идентификационного токена без дополнительных обменов данными или задержек.
  • Синхронизация сессий между бэкэндом и фронтендом. Это решение подходит для приложений, которым необходимо получать доступ к сервисам Firebase, таким как Realtime Database, Firestore и т. д., а также к некоторым внешним серверным ресурсам (база данных SQL и т. д.). Кроме того, к одной и той же сессии можно получить доступ из сервис-воркера, веб-воркера или общего воркера.
  • Устраняет необходимость включения исходного кода Firebase Auth на каждой странице (снижает задержку). Сервис-воркер, загружаемый и инициализируемый один раз, будет управлять сессиями для всех клиентов в фоновом режиме.

Обзор

Firebase Auth оптимизирован для работы на стороне клиента. Токены сохраняются в веб-хранилище. Это упрощает интеграцию с другими сервисами Firebase, такими как Realtime Database, Cloud Firestore, Cloud Storage и т. д. Для управления сессиями на стороне сервера необходимо получить и передать на сервер токены ID.

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 , оно лучше всего подходит для серверных приложений, использующих httpOnly cookie, и его сложнее управлять, поскольку клиентские токены и серверные токены могут рассинхронизироваться, особенно если вам также необходимо использовать другие клиентские сервисы 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());
});

Изменения на стороне клиента

Если поддерживается, сервис-воркер необходимо установить на странице входа/регистрации на стороне клиента.

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

Изменения на стороне сервера

Серверный код сможет обнаруживать токен ID при каждом запросе. Такое поведение поддерживается 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 .