W wielu aplikacjach webowych w przeglądarce wyświetlane są przeróżne treści. Wiele z nich są dostępne tylko dla zalogowanych użytkowników, bądź o określonej roli. Często API jest zabezpieczone czy to poprzez ciasteczko JSESSIONID, czy też JWT. O ile w przypadku pierwszego nie ma raczej problemu z obsługą z poziomu przeglądarki, to w przypadku JWT już napotkamy jeden problem. Jaki?
W wielu aplikacjach webowych w przeglądarce wyświetlane są przeróżne treści. Wiele z nich są dostępne tylko dla zalogowanych użytkowników, bądź o określonej roli. Często API jest zabezpieczone czy to poprzez ciasteczko JSESSIONID, czy też JWT. O ile w przypadku pierwszego nie ma raczej problemu z obsługą z poziomu przeglądarki, to w przypadku JWT już napotkamy jeden problem. Jaki?
Oprócz zwykłych danych w postaci JSON, gdzie przekazywane są dane w postaci tekstowej, mamy też inne zasoby, które chcemy zabezpieczyć. Przykładem są grafiki/zdjęcia, które nie chcemy aby dostęp do nich nie był autoryzowany. Jaki jest na nie sposób? Możemy przekazywać je na front w formacie BASE64, ale to niestety zwiększa rozmiar pliku. W aplikacjach webowych jest to niepożądane. Możemy je też przekazywać w postaci binarnej, gdzie content-type jest ustawiony w postaci image/*. Tutaj rozmiar nie ulega zmianie. Tylko jak wyświetlić taki zasób? Najprościej używając znacznika HTML
. Tylko o ile w zwykłym zapytaniu np za pomocą Fetch API przekazujemy nagłówek Authorization, w którym dodajemy nasz token JWT, to w przypadku takiego znacznika nie mamy takiej możliwości. Ale na pewno? Do tego możemy wykorzystać service-worker.<img />
Rys. 1 – Przykład zabezpieczonego zasobu
Zanim opiszę jak to zrobić, zobaczmy w skrócie czym dokładnie jest service-worker. To narzędzie wykorzystywane jako proxy w architekturze klient-serwer, gdzie istnieje możliwość cache’owania danych w przypadku braku połączenia z siecią. Jest to niezwykle użyteczne, bo pozwala zasymulować aplikację natywną w oknie przeglądarki np. zapisując aplikację jako PWA na urządzeniu mobilnym.
Sam service worker jest skryptem uruchamianym w przeglądarce, działający niezależnie od widocznej na ekranie stronie. Z poziomu kodu nie mamy możliwości dostępu z niego do drzewa DOM czy Web Storage API (localStorage, sessionStorage). Jedynym sposobem na przekazywanie danych do takiego workera jest API Client.postMessage(), albo baza danych IndexedDB dostępna we wszystkich przeglądarkach.
Na samym początku spróbujmy zarejestrować w przeglądarce taki skrypt. Pierwszym krokiem jest dodanie inicjalizacji skryptu w kodzie naszej aplikacji. Fragment kodu poniżej sprawdza czy przeglądarka wspiera takie rozwiązanie i wskazujemy na plik z kodem takiego worker:
if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register('/service-worker.js') .then((registration) => { console.info( 'ServiceWorker registration successful with scope: ', registration.scope, ); return registration; }) .catch((error) => { console.info('ServiceWorker registration failed: ', error); }); }); }
Następnie musimy utworzyć taki plik w folderze, który będzie hostowany na serwerze. Zwykle w projektach jest folder public, który zawiera przetranspilowany i zminimalizowany kod produkcyjny, oraz treść statyczna. Utwórzmy tam plik o nazwie service-worker.js
. Dorzućmy do niego kod:
self.addEventListener('install', function (event) { // Skip the 'waiting' lifecycle phase, to go directly from 'installed' to 'activated', even if // there are still previous incarnations of this service worker registration active. console.log('install'); event.waitUntil(self.skipWaiting()); }); self.addEventListener('activate', async function (event) { console.log('activate'); // Claim any clients immediately, so that the page will be under SW control without reloading. event.waitUntil(self.clients.claim()); });
Sprawdźmy teraz czy udało się zarejestrować poprawnie nasz service worker w aplikacji. Uruchommy ją lokalnie i w konsoli narzędzi deweloperskich przeglądarki powinniśmy zobaczyć następujące wywołania:
Rys. 2 – Poprawne zarejestrowanie service-workera
Rys. 3 – Podgląd statusu service-workera zarejestrowanego w przeglądarce
Mamy już poprawnie zarejestrowany service-worker, ale co dalej? Jak uwierzytelnić zapytanie HTTP takiego zasobu? Jeśli mamy w aplikacji już obsługę JWT do pobierania treści to nie pozostaje nic innego, jak w miejscu gdzie mamy jej implementację wykorzystać taki token i przekazać go do service-workera.
Jak wcześniej wspominałem, ten skrypt niestety nie ma dostępu do Web Storage API, więc skorzystajmy z bazy danych IndexedDB. Do tego celu dodajmy zależność do naszej aplikacji:
$ npm i --save idb
IDB jest biblioteką opartą o obiekt Promise, a to ułatwi pracę z tą bazą, która wykorzystuje mechanizm callbacków do operowania na transakcjach. Spróbujmy więc utworzyć tę bazę i zapiszmy w niej nasz token:
const dbPromise = idb.openDB('appDB', 1, { upgrade(upgradeDb) { if (!upgradeDb.objectStoreNames.contains('token')) { upgradeDb.createObjectStore('token', { keyPath: 'key' }); } }, }); dbPromise.then((db) => { let tx = db.transaction('token', 'readwrite').objectStore('token'); tx.put({ key: 'token', value: msg.data.token }); });
Ok, mamy już zapisany nasz token. Ale teraz musimy go odczytać z poziomu service-workera i dodać do zapytania odpowiedni nagłówek. Aby to zrobić musimy w pliku service-worker.js
podpiąć się pod event fetch, który jest wykorzystywany do pobierania wszelkich zasobów przez HTTP. W takim listenerze, musimy podpiąc się pod konkretne adresy URL, aby przechwycić zapytanie i je zmodyfikować. Odczytać z bazy nasz token i wykorzystać go w requescie. Spróbujmy więc napisać taką obsługę:
self.addEventListener('fetch', function (event) { event.respondWith( (async function () { let response = undefined; if (event.request.url.includes('/resource')) { if (!('indexedDB' in self)) { console.log("SW.js: This browser doesn't support IndexedDB"); return fetch(event.request); } const dbPromise = idb.openDB('newExpertise', 1, (upgradeDb) => { if (!upgradeDb.objectStoreNames.contains('token')) { upgradeDb.createObjectStore('token', { keyPath: 'key' }); } }); const token = await dbPromise.then((db) => { let tx = db.transaction('token', 'readonly'); let store = tx.objectStore('token'); return store.get('token'); }); response = await fetch(event.request, { mode: 'cors', credentials: 'include', headers: { Authorization: `Bearer ${token.value}` }, }); } else { response = await fetch(event.request); } return response; })(), ); });
Tak, teraz możemy sprawdzić czy w zapytaniach po zdjęcia dodawany jest token uwierzytelniający i czy dostaniemy w ogóle jakieś dane. W narzędziach deweloperskich przeglądarki w zakładce Sieć powinniśmy móc zobaczyć nasze zapytanie wraz z jego nagłówkami i statusem:
Rys. 4 – Odpowiedź zapytania HTTP
W łatwy i szybki sposób udało się nam obsłużyć pobieranie i wyświetlanie zdjęć ukrytych za security systemu. To prosty przykład który można odpowiednio rozbudować między innymi o cacheowanie tych treści z wykorzystaniem Cache API. Te rozwiązanie ma przewagę nad innymi sposobami, bo pozwala na wcześniej wspomniane cacheowanie zasobów, ale też prostotę i generyczność obsługi. W jednym miejscu mamy implementację, która nie będzie sprawiała problemu deweloperom. Nie muszą pamiętać o używaniu jakiegoś narzędzia czy konkretnej implementacji aby pobrać taki zasób. Działa to niezależnie od aplikacji i nie trzeba się nim przejmować 😉
We’re a team of experienced and skilled software developers – and people you’ll enjoy working with.
Start Your Project