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:
- 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.
- 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.
- Przechwycenie odpowiedzi za pomocą własnego HandlerInterceptor. Jeśli serwer uwierzytelniania jest w tej samej domenie, co REST API, to stąd utworzymy ciastko.
- Nie zmieniać zachowania serwera autentykacji, lecz stworzyć dodatkowy endpoint, któremu przekażemy JWT i który ustawi ciastko z tym JWT.
- Może znacie lepszy pomysł?