Bezpieczeństwo tokenów JWT i zapis do ciastek w Spring Security

0

Jest system o następującej architekturze:

  • front napisany w Angularze
  • aplikacja do uwierzytelniania w Spring Boot (służy tylko do logowania)
  • aplikacja REST API w Spring Boot schowana za proxy (wymaga JWT)

Proxy dekoduje JWT i przekazuje ID użytkownika do aplikacji REST API.

Obecnie gdy użytkownik loguje się, to serwer uwierzytelniania zwraca token JWT, który zapisuję w sessionStorage. Nie jest to bezpieczne, można wykraść token za pomocą ataku XSS. Zaleca się trzymać go w ciastkach z flagą httpOnly.

Trzymanie JWT w ciastkach też nie jest bez wad:

1. Token w sessionStorage - możliwy atak XSS

Mamy gdzieś dziurę w aplikacji i dochodzi do ataku XSS. Wystarczy zezwolić na kod HTML sanitizer.bypassSecurityTrustHtml(post.content) i można wykraść JWT. W aplikacji zezwalamy na HTML, żeby umożliwić formatowanie tekstu. W przyszłości jeśli odfiltrujemy skrypty, to nie można wykluczyć, że do XSS nie dojdzie w inny sposób.

2. Token w ciasteczkach - możliwy atak CSRF

Zalecają trzymać JWT w ciastku z flagą httpOnly. Nie da się wykraść JWT za pomocą skryptów, ale możliwy jest atak CSRF. W bezstanowym REST API nie tworzy się tokenów anty-CSRF. Stosując kombo XSS+CSRF jesteśmy w stanie wykraść dużo danych i wykonywać operacje bez wiedzy użytkowników.

Jest ograniczenie, że serwer uwierzytelniania musi znajdować się w tej samej domenie, aby mógł utworzyć ciastko z JWT widoczne dla REST API.

3. Podejście hybrydowe

W specyfikacji OAuth znajdziemy kilka sposobów uwierzytelniania. Obecnie w aplikacji stosuję "resource owner password flow". Dane logowania są wymieniane bezstanowo na token JWT. Chcąc się zabezpieczyć zarówno przed XSS jak i CSRF, trzeba by w jakiś sposób rozbić ten token lub generować 2 tokeny - jeden trzymać w ciastku httpOnly, drugi w sessionStorage i aby oba były potrzebne do autoryzacji przez Envoy proxy. Ktoś ma jakiś pomysł?

Inny pomysł to trzymać jakiś token (refresh token?) w ciastkach httpOnly, a JWT w zmiennej stanu aplikacji. Jeśli nastąpi atak XSS, to atakujący nie wyciągnie ani refresh tokenu (bo jest w ciastku z flagą httpOnly), ani JWT (przy dobrej hermetyzacji kodu). Po odświeżeniu strony wymieniamy refresh token na JWT.

Jeśli ten pomysł jest dobry, to można by go zaimplementować. Jest jednak pewien problem z refresh tokenem, bo serwer musiałby przechowywać stan.

Inny flow będzie w aplikacji mobilnej. Tu musimy dostawać w odpowiedzi JWT jak dotychczas.

Implementacja w Javie

Tworząc ten wątek chciałem trzymać JWT w ciastkach httpOnly.

Konfiguracja serwera uwierzytelniania wygląda następująco:

@Configuration
@EnableAuthorizationServer
@RequiredArgsConstructor
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {

  private final AuthenticationManager authenticationManager;

  // Spring wystawia własne adresy do uwierzytelniania i tutaj można je skonfigurować
  public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints.authenticationManager(authenticationManager)
      .tokenStore(new JwtTokenStore(converter()))  // że chcemy dostać token JWT, a nie access token
      .accessTokenConverter(converter());          // dodajemy więcej danych do tokenu
  }

  @Override
  public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
      .withClient("nazwa_klienta")
      .secret("{noop}haslo_klienta")     // i tak musimy trzymać go jawnie w aplikacji webowej
      .authorizedGrantTypes("password")  // uwierzytelnianie przy pomocy hasła
      .scopes("read");
  }
}

Spring wystawia swój własny endpoint /oauth/token w pliku TokenEndpoint.java i zwraca w odpowiedzi token JWT.

Zatem jak utworzyć ciastko z tokenem JWT lub jakimkolwiek innym tokenem?

Miałem kilka pomysłów:

  1. Stworzyć filtr i wpiąć do Security Filter Chain. Tak się nie da, bo filtry są wykonywane przed wywołaniem metody z kontrolera, a uwierzytelnienie użytkownika i wygenerowanie tokenu JWT następuje w kontrolerze /oauth/token.
  2. Podpiąć się pod zdarzenie AuthenticationSuccessEvent. Mamy dostęp do danych zalogowanego użytkownika, lecz token JWT jeszcze nie zdążył się wygenerować. Poza tym nie mamy obiektu Response.
  3. Przechwycenie odpowiedzi za pomocą własnego HandlerInterceptor. Jeśli serwer uwierzytelniania jest w tej samej domenie, co REST API, to stąd utworzymy ciastko.
  4. Nie zmieniać zachowania serwera autentykacji, lecz stworzyć dodatkowy endpoint, któremu przekażemy JWT i który ustawi ciastko z tym JWT.
  5. Może znacie lepszy pomysł?
5

Ale JWT został stworzony z myślą właśnie o tym, żeby dało się go w bezpieczny sposób trzymać u klienta.

6

@Shiba Inu, dla mnie to brzmi tak jakby próbowałeś wymyślić bardziej okrągłe koło niż te istniejące.
Powiem ci tak: - to klient ma dbać o bezpieczeństwo swoich danych, nie ty.
Bo jak widzę w swoich wywodach zapomniałeś że użyszkodnik może:

  • Wkleić tokien sobie na FB (lub inne społecznościówki)
  • Wysłać koledze mailem
  • Zapisać jawnym tekstem w notatniku i umieścić na pulpicie
  • itp. itd.
    Twoja wyobraźnia jest zbyt ograniczona (moja też i każdego innego programisty również) zdrowym rozsądkiem aby przewidzieć wszystko co może wymyślić użyszkodnik aby zrobić sobie kuku, więc nawet nie próbuj z nim walczyć, on i tak "wygra" :)
1

No dobra, ale przecież można zadbać o to, żeby nie było podatności XSS/CSRF. Zarówno po stronie serwera, jak i klienta zresztą. Jeżeli klient ma mieć możliwość korzystania z jakiejś usługi, to musi mieć dane do uwierzytelnienia się w tej usłudze. Jeśli je ma, to można je jakoś wykraść. Jak nie masz zwyczaju zamykać drzwi, to montaż najwymyślniejszych zamków wiele nie pomoże.
Oczywiście można zabezpieczać krytycznych użytkowników przed skutkami. Np. podmienić jakieś krytyczne metody na cokolwiek innego niż GET, albo dodawać dodatkowe sessionid do parametrów URL'a, ale to już raczej taka ostatnia bariera.
SecureToken nie musi być przechowywany po stronie serwera (podobnie zresztą jak JWT), tak długo jak zabezpieczany endpoint jest w stanie zweryfikować ten dodatkowy składnik.

Np. coś w tym stulu:
`hmac(uuid, tajneHasło)+uuid'
Endpoint zabezpieczany rozkłada to sobie na dwie części, sprawdza, czy jak zrobi hasha uuid ze swoim tajnym hasłem, to dostanie token..

Ale tak jak pisałem wyżej - jeżeli atakujący jest w stanie skłonić czyjąś przeglądarkę do wykonania swojego kodu, albo zmusić ją do wykonania jakiegoś calla, to robi się już trochę smutno.

1
Riddle napisał(a):

Ale JWT został stworzony z myślą właśnie o tym, żeby dało się go w bezpieczny sposób trzymać u klienta.

Raczej nie.

Według mnie w ogóle JWT został wymyślony do innych scenariuszy niż ostatnio większość go używa.

U @Shiba Inu to API jest wystawione tylko na potrzeby frontu, który jest napisany w SPA.
Dlaczego nie użyć standardowego ciasteczka sesyjnego? Co tutaj wnosi JWT poza tym, że jest "modnie", a i tak zazwyczaj kończy się na tym, że trzeba emulować zachowanie ciasteczka sesyjnego za pomocą tego JWT (np. unieważnianie tokenów, możliwość wylogowania ze wszystkich urządzeń na raz).
Jak dla mnie fakt zalogowania użytkownika jest właśnie rozpoczęciem sesji, a po wylogowaniu sesja powinna być zakończona.
Tak, można do tego zastosować JWT i obudowywać je mechanizmami sesyjnymi, tylko co to wnosi?

Shiba Inu napisał(a):

W bezstanowym REST API nie tworzy się tokenów anty-CSRF

Tu sie zgodzę.
Tylko nie wiem, czy API wystawione na potrzeby frontu powinno się do tego stosować. W takim publicznym REST API wystawionym na potrzeby klientów zewnętrznych nie stosuje się też challengy captcha, co może być przydatne w przypadku gdy masz wystawiony publiczny frontend, takie publiczne API zazwyczaj ma jakieś rate limitingi, ma pełne wsparcie OAuth2, a nie jakąś atrapę, która polega na wysyłaniu hasła i otrzymywaniu tokena itp.

1 użytkowników online, w tym zalogowanych: 0, gości: 1