Service Worker'larla oturum yönetimi

Firebase Auth, oturum yönetimi için Firebase kimliği jetonlarını algılamak ve iletmek amacıyla hizmet çalışanlarını kullanma olanağı sağlar. Bu, aşağıdaki avantajları sağlar:

  • Her ek işlem gerekmeden sunucudan gelen her HTTP isteğinde kimlik jetonu iletme özelliği.
  • Ek gidiş dönüş veya gecikmeler olmadan kimlik jetonunu yenileyebilme.
  • Arka uç ve ön uç senkronize edilmiş oturumlar. Realtime Database, Firestore gibi Firebase hizmetlerine ve bazı harici sunucu tarafı kaynaklara (SQL veritabanı vb.) erişmesi gereken uygulamalar bu çözümü kullanabilir. Ayrıca aynı oturuma Service Worker, web çalışanı veya paylaşılan çalışan da erişilebilir.
  • Her sayfaya Firebase Auth kaynak kodu ekleme ihtiyacını ortadan kaldırır (gecikmeyi azaltır). Bir kez yüklenen ve başlatılan hizmet çalışanı, arka planda tüm istemciler için oturum yönetimini gerçekleştirir.

Genel bakış

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

Web Modüler API'si

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 ad alanına sahip API'sı

firebase.auth().currentUser.getIdToken()
  .then((idToken) => {
    // idToken can be passed back to server.
  })
  .catch((error) => {
    // Error occurred.
  });

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

Bu ölçeklenmeyebilir ve bunun yerine sunucu tarafı oturum çerezleri gerekebilir. Kimlik jetonları oturum çerezleri olarak ayarlanabilir ancak bunlar kısa ömürlüdür ve istemciden yenilenmeleri ve daha sonra, kullanıcı siteyi bir süredir ziyaret etmemişse ek bir gidiş-dönüş gerektirebilecek geçerlilik süresi dolan yeni çerezler olarak ayarlanmaları gerekir.

Firebase Auth daha geleneksel çerez tabanlı oturum yönetimi çözümü sağlasa da, bu çözüm en iyi sunucu tarafı httpOnly çerez tabanlı uygulamalarda çalışır ve istemci jetonları ile sunucu tarafı jetonlarının senkronizasyonu bozulabileceği için, özellikle de başka istemci tabanlı Firebase hizmetlerini kullanmanız gerekiyorsa bu çözümün yönetimi daha zordur.

Bunun yerine, sunucu tarafı tüketimine yönelik kullanıcı oturumlarını yönetmek için Service Worker'lar kullanılabilir. Bu, aşağıdaki nedenlerle çalışır:

  • Service Worker'lar mevcut Firebase Auth durumuna erişebilir. 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 alma özelliğini dahil etmesi gerekir.

Web Modüler API'si

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 ad alanına sahip API'sı

// 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. Kimlik jetonu varsa başlık aracılığıyla isteğe eklenir. Sunucu tarafında ise 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 Modüler API'si

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 ad alanına sahip API'sı

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 ek işlem yapılmadan her zaman üstbilgide geçirilen bir kimlik jetonuna sahip olur.

Service Worker'ın Yetkilendirme durumu değişikliklerini algılayabilmesi için bu özelliğin oturum açma/kayıt sayfasına yüklenmesi gerekir. Hizmet çalışanının paket haline getirilmiş olduğundan, tarayıcı kapatıldıktan sonra da çalışmaya devam ettiğinden emin olun.

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

Web Modüler API'si

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

Web ad alanına sahip API'sı

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

İstemci tarafında yapılan değişiklikler

Destekleniyorsa hizmet çalışanının, istemci tarafında oturum açma/kayıt sayfasına yüklenmesi gerekir.

Web Modüler API'si

// Install servicerWorker if supported on sign-in/sign-up page.
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js', {scope: '/'});
}

Web ad alanına sahip API'sı

// 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 Modüler API'si

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 ad alanına sahip API'sı

// 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ındaki değişiklikler

Sunucu tarafı kodu, her istekteki kimlik jetonunu algılayabilir. Bu davranış, Node.js için Yönetici SDK'sı veya FirebaseServerApp kullanan Web SDK'sı ile 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'si

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ğından ve hizmet çalışanlarının aynı kaynaktan çalışacak şekilde kısıtlanmasından dolayı CSRF riski yoktur. Uç noktalarınızı çağırmaya çalışan farklı kaynaklı bir web sitesi, Service Worker'ı çağıramaz ve isteğin, sunucunun bakış açısından kimliği doğrulanmamış olarak görünmesine neden olur.

Service Worker'lar artık başlıca modern tarayıcıların tamamında desteklense de bazı eski tarayıcılar bunları desteklemez. 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ıtlanabilirken kimlik jetonunu sunucunuza aktarmak için bir yedek yedek gerekebilir.

Hizmet çalışanlarının yalnızca tek kaynaklı olduğunu ve yalnızca https bağlantısı veya localhost aracılığıyla sunulan web sitelerine yükleneceklerini unutmayın.

caniuse.com adresinden Service Worker için tarayıcı desteği hakkında daha fazla bilgi edinebilirsiniz.