Service Worker'larla oturum yönetimi

Firebase Auth, oturum yönetimi amacıyla Firebase kimlik jetonlarını algılamak ve aktarmak için Service Worker'ları kullanabilmenizi sağlar. Bu durum, şu avantajları sağlar:

  • Sunucudan gelen her HTTP isteğinde ek bir işlem yapmadan kimlik jetonu iletebilme olanağı.
  • Kimlik jetonunu ek gidiş dönüş veya gecikmeler olmadan yenileyebilme imkanı.
  • Arka uç ve ön uç senkronize oturumları. Realtime Database ve Firestore gibi Firebase hizmetlerine ve harici sunucu tarafı kaynaklara (ör. SQL veritabanı) erişmesi gereken uygulamalar bu çözümü kullanabilir. Buna ek olarak aynı oturuma hizmet çalışanı, web çalışanı veya paylaşılan çalışandan da erişilebilir.
  • Her sayfaya Firebase Auth kaynak kodu ekleme ihtiyacını ortadan kaldırır (gecikmeyi azaltır). Bir kez yüklenip başlatılan hizmet çalışanı, tüm istemcilerin oturum yönetimini arka planda yönetir.

Genel bakış

Firebase Auth, istemci tarafında çalışacak şekilde optimize edilmiştir. Jetonlar, web depolama alanına kaydedilir. Böylece Realtime Database, Cloud Firestore ve Cloud Storage gibi diğer Firebase hizmetleriyle entegrasyon da kolaylaşır. Oturumları sunucu tarafı bakış açısından yönetmek için kimlik jetonlarının alınması ve sunucuya iletilmesi gerekir.

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

Ancak bu, en yeni kimlik jetonunu almak için bazı komut dosyalarının istemciden çalıştırılması ve ardından bunu istek başlığı, POST gövdesi vb. aracılığıyla sunucuya iletmesi gerektiği anlamına gelir.

Bu durumda ölçeklenmeyebilir ve bunun yerine sunucu tarafı oturum çerezleri gerekebilir. Kimlik jetonları oturum çerezleri olarak ayarlanabilir ancak bu çerezler kısa ömürlüdür ve bunların istemciden yenilenmesi ve ardından geçerlilik süresi sona erdiğinde yeni çerezler olarak ayarlanması gerekir. Kullanıcı siteyi bir süredir ziyaret etmemişse ek bir gidiş dönüş gerekebilir.

Firebase Auth, daha geleneksel bir çerez tabanlı oturum yönetimi çözümü sunar. Ancak bu çözüm, en iyi sunucu tarafı httpOnly çerezi tabanlı uygulamalarda çalışır ve özellikle diğer istemci tabanlı Firebase hizmetlerini de kullanmanız gerektiğinde istemci jetonları ile sunucu tarafı jetonlar senkronize edilebileceği için yönetilmesi daha zordur.

Bunun yerine, sunucu tarafı tüketim için kullanıcı oturumlarını yönetmek amacıyla Service Worker'lar kullanılabilir. Bunun nedeni aşağıdakilerden biri olabilir:

  • Hizmet çalışanlarının mevcut Firebase Auth durumuna erişimi vardır. Geçerli kullanıcı kimliği jetonu, hizmet çalışanından alınabilir. Jetonun süresi dolarsa istemci SDK'sı jetonu yeniler ve yeni bir jeton döndürür.
  • Service Worker'lar, getirme isteklerine müdahale edip bunları değiştirebilir.

Hizmet çalışanı değişiklikleri

Service Worker'ın, Auth kitaplığını ve kullanıcı oturum açtığında geçerli kimlik jetonunu alabilme özelliğini içermesi gerekir.

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

Uygulamanın kaynağına gönderilen tüm getirme isteklerine müdahale edilir ve kimlik jetonu varsa isteğe başlık aracılığıyla eklenir. Sunucu tarafında, istek başlıkları kimlik jetonu için kontrol edilir, doğrulanır ve işlenir. Service Worker komut dosyasında, getirme isteğine müdahale edilir ve istek değiştirilir.

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

Sonuç olarak, kimliği doğrulanmış tüm istekler her zaman ek işleme olmadan başlıkta iletilmiş bir kimlik jetonuna sahip olur.

Hizmet çalışanının, Kimlik doğrulama durumu değişikliklerini algılaması için oturum açma/kaydolma sayfasına yüklenmesi gerekir. Service Worker'ın, tarayıcı kapatıldıktan sonra da çalışmaya devam edecek şekilde paketlendiğinden emin olun.

Kurulumdan sonra hizmet çalışanının, etkinleştirme sırasında clients.claim() işlevini çağırması gerekir. Böylece, geçerli sayfa için denetleyici olarak ayarlanabilir.

Web

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

Web

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

İstemci tarafı değişiklikler

Destekleniyorsa hizmet çalışanı, istemci tarafı oturum açma/kaydolma sayfasına yüklenmelidir.

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: '/'});
}

Kullanıcı oturum açıp başka bir sayfaya yönlendirildiğinde hizmet çalışanı, yönlendirme tamamlanmadan önce kimlik jetonunu başlığa ekleyebilir.

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

Sunucu tarafı değişiklikler

Sunucu tarafı kodu, her istekte kimlik jetonunu algılayabilir. Bu davranış, Node.js için Yönetici SDK'sı veya FirebaseServerApp kullanan Web SDK'sı tarafından desteklenir.

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 modüler 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');
    }

    // ...
}

Sonuç

Ayrıca, kimlik jetonları hizmet çalışanları aracılığıyla ayarlanacağı ve hizmet çalışanlarının aynı kaynaktan çalışacak şekilde kısıtlanması nedeniyle CSRF riski yoktur. Çünkü uç noktalarınızı çağırmaya çalışan farklı bir kaynağa sahip web sitesi, hizmet çalışanını çağıramaz ve bu da isteğin sunucu bakış açısıyla kimliği doğrulanmamış olarak gösterilmesine neden olur.

Service Worker'lar artık başlıca tüm modern tarayıcılarda desteklense de bazı eski tarayıcılar bunları desteklememektedir. Sonuç olarak, Service Worker'lar kullanılamadığında veya bir uygulama yalnızca Service Worker'ları destekleyen tarayıcılarda çalışacak şekilde kısıtlandığında kimlik jetonunu sunucunuza aktarmak için bazı yedek gerekebilir.

Hizmet çalışanlarının yalnızca tek kaynaklı olduğunu ve sadece https bağlantısı veya yerel ana makine üzerinden hizmet veren web sitelerine yükleneceğini unutmayın.

Service Worker için tarayıcı desteği hakkında daha fazla bilgiye caniuse.com adresinden ulaşabilirsiniz.