Управление сеансами с помощью сервис-воркеров

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

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

Обзор

Firebase Auth оптимизирован для работы на стороне клиента. Токены сохраняются в хранилище браузера. Это позволяет легко интегрировать его с другими сервисами 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, которое лучше всего подходит для приложений на стороне сервера httpOnly, использующих файлы cookie, но сложнее в управлении, поскольку клиентские и серверные токены могут рассинхронизироваться, особенно если вам также нужно использовать другие клиентские сервисы Firebase.

Вместо этого для управления сеансами пользователей на стороне сервера можно использовать сервис-воркеры. Это возможно благодаря следующим факторам:

  • У сервисных работников есть доступ к текущему состоянию Firebase Auth. Текущий токен идентификатора пользователя можно получить от сервисного работника. Если срок действия токена истек, клиентский SDK обновит его и вернет новый.
  • Сервисные работники могут перехватывать запросы на получение данных и изменять их.

Изменения в скриптах Service Worker

Сервисному работнику потребуется библиотека Auth и возможность получать текущий токен идентификатора, если пользователь вошел в аккаунт.

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

Все запросы на получение данных из источника приложения будут перехватываться, и если доступен токен идентификатора, он будет добавлен в запрос через заголовок. На стороне сервера заголовки запроса будут проверены на наличие токена идентификатора, а затем он будет проверен и обработан. В скрипте 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 мог обнаруживать изменения состояния Auth, его необходимо установить на странице входа или регистрации. Убедитесь, что 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('/'));

Модульный 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 worker, а service worker могут работать только в пределах одного источника, риск CSRF отсутствует. Если сайт с другим источником попытается вызвать ваши конечные точки, ему не удастся запустить service worker, и с точки зрения сервера запрос будет неаутентифицированным.

Хотя сервис-воркеры поддерживаются во всех современных браузерах, в некоторых старых версиях они не работают. Поэтому, если сервисные работники недоступны, может потребоваться резервный способ передачи токена идентификатора на ваш сервер. Также можно ограничить работу приложения только браузерами, которые поддерживают сервисных работников.

Обратите внимание, что service worker могут быть установлены только на сайтах, которые обслуживаются через подключение HTTPS или localhost.

Подробнее о поддержке браузерами файлов service worker можно узнать на сайте caniuse.com.