KamilTroczewski

Zapewne robisz to źle, czyli token na frontendzie 🙅‍♂️

💡

Ten artykuł jest też dostępny w języku angielskim. Jeżeli chcesz to

Jeżeli od razu chcesz przejść do meritum, to artykuł zasadniczo omawia dwie metody:

  1. LocalStorage
  2. Cookie

Wstęp

Wiele aplikacji, które są w sieci, posiadają systemy rejestracji i przyznawania dostępu. Mimo, że taki scenariusz jest często używany, to jego zakodowanie już nie jest takie proste. Bezpieczeństwo aplikacji webowych jest bardzo ważne, ponieważ nie możemy pozwolić komuś na kradzież tożsamości naszych użytkowników. Trzeba to wszystko dobrze przemyśleć zarówno ze strony backendowej, jak i frontendowej. Dlatego ten artykuł omawia, jak powinniśmy obchodzić się z tokenem 🤭

Potwierdzanie tożsamości i przyznawanie dostępu

Użytkownik wchodząc na serwisy takie jak Facebook, Youtube, czy Github, musi potwierdzić swoją tożsamość. Backend uwierzytelnia go, czyli sprawdza czy jest on rzeczywiście tą osobą, za którą się podaje. Następnie gdy użytkownik wykonuje zapytanie do chronionego zasobu, serwer dokonuje autoryzacji, więc sprawdza czy ma on do niego odpowiednie prawa. Do tego potrzebny jest token, który jest w pewnym sensie dowodem osobistym. Jak za pomocą dowodu możemy poznać kogoś dane, tak samo za pomocą tokena, możemy sprawdzić z kim mamy do czynienia. Jak jednak taki token jest wydawany?

Komunikacja aplikacji z serwerem. Użytkownik rejestruje się i loguje, a serwer zwraca mu token

Jak możesz zauważyć, przy 5 punkcie jest napisane - użytkownik jest zalogowany a jego token.... No właśnie, co się dzieje w tym momencie z tokenem? Musimy mieć do niego dostęp, jeżeli chcemy wykonywać zapytania do serwera i poświadczyć, że my to tak naprawdę my. Dlatego warto go zapisać w pamięci przeglądarki, żeby użytkownik za każdym razem gdy wchodzi na stronę, nie musiał ponownie potwierdzać swojej tożsamości.

Przechowywanie tokena w localStorage

Jak już szczęśliwie otrzymaliśmy token, to możemy go zapisać w obiekcie localStorage przeglądarki. W jaki sposób można to zrobić?

const signInUser = async (login, password) => {
  const signInURL = 'http://website.com/auth/signin';
  try {
    const response = await fetch(signInURL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ login, password }),
    });

    const { data } = await res.json();

    const { accessToken } = data;

    // Zapisz token w localStorage
    localStorage.setItem('accessToken', accessToken);
  } catch (err) {
    console.error('Ooops, coś poszło nie tak', err);
  }
};

Przechowywanie danych w localStorage jest jakimś rozwiązaniem, ale trzeba pamiętać, że te dane zostają zapisane w przeglądarce użytkownika, a jak wiadomo wszystko co znajduje się na frontendzie może zostać przez każdego sprawdzone, nawet przez osoby trzecie! Taki sposób jest podatny na ataki XSS i ktoś może wstrzyknąć wywołanie skryptu na naszej stronie, który następnie przechwyci token. A przecież nie chcemy, żeby trafił on w nieodpowiednie ręce.

Token zapisany w obiekcie localStorage

Jak widać, jeżeli strona jest słabo zabezpieczona, to można łatwo pobrać zawartość tokena. Dlatego localStorage to bardzo niebezpieczne rozwiązanie i powinniśmy z niego zrezygnować. Na szczęście mamy jeszcze inne możliwości. Zobaczmy, gdzie indziej można zapisać token.

Ciasteczko (ang. cookie) również pozwala przechowywać informacje na stronie, ale różni się od localStorage tym, że może zostać ustawione zarówno przez serwer backendowy, jak i poprzez aplikację frontendową. Jeżeli zdecydujemy się na ustawienie ciasteczka na frontendzie, to sytuacja się powtórzy - ktoś może je wykraść. Dlatego powinniśmy zapisywać ciasteczko z tokenem poprzez serwer. Być może zapytasz - Co to zmieni jak ustawię ciasteczko przez backend? Przecież to dalej będzie zapisane w przeglądarce. Tak, to prawda, ale możemy wtedy ustawić pewne flagi, które ograniczą do niego dostęp.

Flaga httpOnly

Włączona flaga httpOnly zabezpiecza ciasteczko przed jego odczytaniem w aplikacji i jeżeli sprawdzimy zawartość document.cookie, to go tam nie będzie, co przekłada się na większe bezpieczeństwo. Odciążamy też w ten sposób aplikację frontendową i nie musimy już martwić się o umieszczeniu w nagłówku tokena, ponieważ ciasteczko jest za każdym razem dodawane w zapytaniu.

W przykładzie załóżmy, że frontend i backend są pod tym samym adresem. Wykonujemy zapytanie na frontendzie za pomocą funkcją fetch, która jest wbudowana w przeglądarce, jednak żeby nasz token został dołączony musimy dodać jedną rzecz.

fetch(signInURL, {
  method: 'POST',
  credentials: 'same-origin', // Trzeba to ustawić!
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ login, password }),
});

Gdy ustawimy opcję credentials na same-origin, to ciasteczka będą dołączone do zapytania, ale tylko w obrębie tego samego origin. Czyli token, który znajduje się w ciasteczku, będzie przesyłany za każdym razem, ale tylko do naszego do serwera. Natomiast origin jest tym co widzimy w naszym pasku adresu przeglądarki. Składa się on z protokołu (HTTP lub HTTPS), domeny oraz portu. Jeżeli któraś z tych trzech rzeczy się różni, to credentials z wartością same-origin zablokuje wysłanie ciasteczka do tego serwera.

Origin składa się z protokołu, domeny oraz portu

Jako ciekawostkę, można też ustawić opcję credentials na include, co umożliwia przesyłanie ciasteczek między róźnymi origin, ale to już nie jest bezpieczne, ponieważ inne serwery backendowe mogłyby odczytać nasze ciasteczko.

Teraz na backendzie musimy ustawić naszemu ciasteczku flagę httpOnly na true. W tym przykładzie serwer jest postawiony na NodeJS przy wsparciu ExpressJS.

const MAX_AGE_1_MONTH = 1000 * 3600 * 24 * 30; // w milisekundach
res.cookie('access_token', accessToken, {
  httpOnly: true,
  maxAge: MAX_AGE_1_MONTH,
});

I już mamy bezpieczniejszą autoryzację w naszej aplikacji. Ciasteczko access_token nie będzie zwracane gdy podejrzymy zawartość document.cookie na frontendzie.

Flaga secure

Ciasteczko z flagą secure, jak sama nazwa może sugerować, jest tylko wysyłane do serwera gdy zapytanie jest szyfrowane protokołem HTTPS. Jeżeli nasza strona nie wykorzystuje tego protokołu, to token nie zostanie przesłany. Chroni nas to przed przypadkowym wejściem użytkownika na naszą stronę przez protokół HTTP, który by wysyłał niebezpieczne zapytania.

res.cookie('access_token', accessToken, {
  httpOnly: true,
  secure: true,
  maxAge: MAX_AGE_1_MONTH,
});

Flaga sameSite

Flaga sameSite wprowadza restrykcje z jakich domen mogą wychodzić autoryzowane zapytania. Zabezpiecza nas to przed stronami, które wykonują zapytania do naszej strony wraz z podpiętym tokenem. To znaczy, że nikt z nieupoważnionej strony nie będzie mógł pobierać zasobów do których nie ma dostępu, nawet gdy taki ktoś ma podpięty token w ciasteczku. Ten rodzaj ataku ma nazwę CSRF.

Jako sameSite można ustawić wartość secure, ale ta opcja jest zbyt restrykcyjna i blokuje linki, które są na innych stronach i kierują do naszego serwisu. Dlatego istnieje również opcja Lax, która jest złotym środkiem, ponieważ blokuje odbieranie ciasteczek z innych domen, ale umożliwia robić przekierowania do naszej strony.

res.cookie('access_token', accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: MAX_AGE_1_MONTH,
});

Już teraz nasza aplikacja jest o wiele bezpieczniejsza, niż to co mieliśmy na początku. Teraz tylko rola serwera backendowego, żeby sprawdzał przychodzące do niego zapytania. Bardzo dobrze do tego nadaje się middleware, ponieważ weryfikuje czy token jest nieprawidłowy lub czy go nie ma wcale, a jeżeli wszystko jest poprawne, to zwraca żądany zasób.

const authMiddleware = (req, res, next) => {
  const { access_token: accessToken } = req.cookies;

  if (!accessToken) {
    return res.status(401).json({ status: 401, message: 'Brak ciasteczka z tokenem' });
  }

  try {
    jwt.verify(accessToken, getEnv('ACCESS_TOKEN_SECRET'));
    next();
  } catch {
    res.status(401).json({ status: 401, message: 'Nieprawidłowy token' });
  }
};

Podsumowanie

Jak widać jest dużo możliwości jak obchodzić się z tokenem na frontendzie. Niektóre rozwiązania są mniej bezpieczne (tak, patrzę na Ciebie localStorage 👀), a inne bardziej. Dlatego ten temat trzeba dobrze zrozumieć, żeby nie narażać naszych użytkowników na niebezpieczeństwo.