Refresh token i wiele requestów w Angularze
Jeśli tworzysz aplikację w Angularze to jest duża szansa, że korzystasz z czegoś takiego jak JSON Web Token. Jest to bardzo wygodne rozwiązanie pozwalające potwierdzać tożsamość użytkownika jednocześnie ograniczając ilość zapytań do bazy. Korzystając z tego tokena prawdopodobnie korzystasz też z mechanizmu odświeżania tokena. Jest bardzo dużo tekstów, które opisują jak to robić. Jednak 99% z nich pomija jeden istotny problem – jak rozwiązać odświeżanie tokena kiedy jeden komponent wykonuje kilka requestów? O tym w tym wpisie.
JWT
Podstawowym założeniem JSON Web Tokena (JWT) jest umożliwienie potwierdzenia tożsamości użytkownika za pomocą prostego ciągu znaków, który z jednej strony nie wymaga wielu zasobów do zwalidowania, a z drugiej strony jest bezpieczny. Pisałem już o nim w innym wpisie.
Jednym z elementów tokena jest jego czas życia. Tak długo jak token jest ważny tak długo można go używać bez konieczności sprawdzania użytkownika w bazie danych. I tutaj rodzi się problem – co jeśli ktoś wykradnie taki latający non stop token? Będzie miał dostęp do wszystkich zasobów tego użytkownika. Aby się przed tym jak najbardziej zabezpieczyć powinno się tworzyć tokeny ważne maksymalnie kilka minut. Jednak w takim przypadku użytkownik po kilku minutach korzystania z aplikacji dostawał by informację, że jego sesja wygasła i musi się zalogować ponownie. Nie jest to w żaden sposób akceptowalne.
Daj mi nowy token!
Również na ten problem znaleziono rozwiązanie. Jest nim mianowicie drugi token służący do odświeżania tego głównego. Ma on zdecydowanie dłuższy czas życia i zawiera jedynie informacje potrzebne do dostania nowego tokena. Nie da się bezpośrednio za jego pomocą dostać do zasobów. Jest używany w przypadku kiedy główny token wygaśnie.
Procedura działa następująco:
- Użytkownik się loguje do aplikacji i dostaje dwa tokeny – jeden główny, który wygaśnie po kilku minutach i drugi odświeżający, który żyje kilkadziesiąt lub więcej minut.
- Wykonując zapytania do API użytkownik zawsze przesyła główny token, na podstawie którego aplikacja potwierdza, że użytkownik jest zaufany i daje mu dostęp do zasobów.
- Token wygasa i serwer dla kolejnego zapytania zwraca błąd 401 (lub inny, który sobie przyjęliśmy) informujący o braku dostępu do zasobów.
- Aplikacja dostając taki błąd bierze token odświeżający i wysyła prośbę o nowy token główny. Jeżeli token odświeżający jest jeszcze ważny (nie wygasł i nie został oznaczony w bazie jako nieważny) i użytkownik istnieje w bazie to generowane są nowe tokeny, a stary token odświeżający jest oznaczany jako nieważny. Wygenerowanie obu tokenów pozwala przedłużyć sesję o kolejne kilkadziesiąt minut – dopóki użytkownik jest aktywny to mu sesja nie wygaśnie. Serwer zwraca oba tokeny. Aplikacja je zapisuje i ponawia ostatnie zapytanie, które zwróciło błąd z powodu nieważnego tokena.
- I ten cykl się powtarza do momentu wylogowania lub wygaśnięcia tokena odświeżającego.
Problem tkwi w szczegółach
Powyższa procedura doskonale sprawdza się dopóki w jednym momencie wykonywane jest jedno zapytanie. Teraz zastanów się co się stanie kiedy użytkownik wejdzie na stronę, która od razu wykona np. dwa zapytania i oba dostaną błąd 401?
Oba zaczną się wykonywać praktycznie w tym samym momencie. Oba dostając 401 wezmą refresh token i będą chciały pobrać nowy token. I teraz pierwsze zapytanie, które trafi do serwera dostaje nowy, poprawny token i refresh token. Zwraca go i próbuje wykonać zapytanie, które zwróciło błąd. Ok. Jednak między czasie dla drugiego zapytania procedura jest taka sama. Wzięty został ten sam refresh token co dla pierwszego z zamiarem odświeżenia tokena głównego. Jednak tym razem serwer już odpowie, że ten token nie jest poprawny. W końcu poprzednie zapytanie go właśnie unieważniło i dostarczyło nowy, o którym to nie wiedziało. A skoro odpowiedź to „token odświeżający jest nieważny” to znaczy tylko jedno – użytkownikowi wygasła sesją bądź się wylogował więc trzeba przekierować na stronę logowania. I tak się właśnie dzieje mimo, że pół sekundy wcześniej świeżutki, poprawny token odświeżający dotarł do aplikacji i pozwala jej używać kolejne kilkadziesiąt minut.
Zaczekaj!
Oczywistym rozwiązaniem, które przychodzi do głowy jest „jakieś” czekanie na odświeżony token. Jednak tutaj kryje się całe sedno problemu – jak to zrobić? Przeglądając internet w poszukiwaniu odpowiedzi trafiłem na rozwiązanie. Od tej pory mówił będę konkretnie w kontekście Angulara 2+ jednak zapewne rozwiązanie jest jak najbardziej do przeniesienia na inne frameworki.
Flaguj
Rozwiązanie o tyle banalne co nie wpadające od razu do głowy. Otóż wystarczy wykorzystać fakt, że interceptor dla zapytań HTTP, czyli miejsce gdzie powinno się łapać m.in. błędy z serwera i odpowiednio nimi zarządzać, żyje dużo dłużej niż nasz jeden komponent, w którym wywołujemy kilka requestów. Dlatego możemy skorzystać z metody starej jak świat czyli współdzielonej flagi.
Idea jest taka:
Jeśli wystąpił błąd 401 sprawdź czy jest ustawiona flaga mówiąca o tym, że jakiś request już się tym zajmuje. Jeżeli nie to zajmij się pobraniem nowego tokena i ustaw flagę. Po pobraniu nowych tokenów i zapisaniu ich wyślij informację o tym poprzez obiekt, który powie innym zapytaniom, że mogą już korzystać z nowego tokena. Jeżeli zaś ktoś się już problemem zajmuje to subskrybuj wartość, która powie Ci, że operacja się zakończyła i wykonaj się kiedy taka informacja się pojawi.
Rozwiązanie oczywiście nie jest idealne zwłaszcza dla perfekcjonistów, ponieważ wprowadza jakiś współdzielony, prawie globalny stan. Jednak działa, mimo wszystko jest w miarę zamknięte i pozwala obsługiwać właściwie dowolnie dużo równoległych zapytań.
Pokaż mi swój kod
Jednak żeby artykuł był kompletny to wypada pokazać gotowe, działające rozwiązanie. Zostało ono okrojone do niezbędnego minimum, a w niektórych miejscach dodałem komentarz zamiast konkretnej metody mówiąc tylko jaka operacja jest tam wykonywana. Oto on:
@Injectable() export class ApiHttpInterceptor implements HttpInterceptor { isRefreshingToken = false; tokenSubject: BehaviorSubject<string> = new BehaviorSubject<string>(null); intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpSentEvent | HttpHeaderResponse | HttpProgressEvent | HttpResponse<any> | HttpUserEvent<any>> { const request = this.applyToken(urlReq); return next.handle(request) .map((event: HttpEvent<any>) => { if (event instanceof HttpResponse) { return event; } }) .catch((error: any) => { if (error instanceof HttpErrorResponse) { if (error.status === 401) { if (!this.isRefreshingToken) { this.isRefreshingToken = true; this.tokenSubject.next(null); return this.getNewToken() .flatMap((res) => { this.tokenSubject.next(res.headers.get('AUTHENTICATION-TOKEN')); localStorage.setItem('token', res.headers.get('AUTHENTICATION-TOKEN')); localStorage.setItem('refreshToken', res.headers.get('REFRESH-TOKEN')); return next.handle(this.applyToken(request)); }) .catch(() => { // wyloguj użytkownika }) .finally(() => { this.isRefreshingToken = false; }); } else { return this.tokenSubject .filter(token => token != null) .take(1) .switchMap(token => { return next.handle(this.applyToken(request)); }); } } } }); } }
Linijka const request = this.applyToken(urlReq); ustawia dla zapytania token wymagany przez API. W moim przypadku jest to ustawienie nagłówka autoryzacji.
Następnie wykonywane jest zapytanie i jeżeli nie było żadnego błędu to zwracana jest odpowiedź i po prostu życie toczy się dalej.
Jednak w przypadku błędu sprawdzamy czy jest to błąd 401 mówiący w moim przypadku o tym, że aktualny token wygasł. Jeśli tak to sprawdzam czy już jakieś zapytanie się tym zajęło sprawdzając odpowiednie pole klasy interceptora.
Jeśli wartość jest ustawiona na false to oznacza, że jestem pierwszym, któremu token wygasł. W takim przypadku informuję, że zająłem się sprawą i przystępuję do działania. Po pierwsze komunikuję, że sprawa nie została jeszcze zakończona poprzez wysłanie nulla do wszystkich oczekujących.
Po tym wywołuję funkcję, która pobierze nowe tokeny. Jest to zwykłe zapytanie HTTP do odpowiedniego adresu w API.
Jeżeli wykonało się poprawnie to wysyłam wiadomość z nowym tokenem do oczekujących i zapisuję otrzymane tokeny w przeglądarce. Ostatecznie ponawiając również próbę przetworzenia requestu, który jako pierwszy dostał błąd 401 jednak tym razem ustawiając mu nowy, świeży token.
W przypadku jak zapytanie o nowy token zwróci błąd wylogowuję użytkownika.
Na sam koniec wyłączam flagę informującą o tym, że zajmuję się sprawą.
W przypadku kiedy ktoś już się tematem zajął oczekuję aż pojawi się stosowna informacja o zakończeniu pobierania nowego tokena i dopiero wtedy ponawiam próbę wykonania zapytania już z nowym tokenem.
Tak więc jak widać całość sprowadza się do dodania dwóch wartości w klasie interceptora:
isRefreshingToken = false; tokenSubject: BehaviorSubject<string> = new BehaviorSubject<string>(null);
A następnie odpowiednie ich ustawianie i kasowanie. Od tej pory jeżeli kilka requestów dostanie informację, że token wygasł to tylko jeden z nich zajmie się sprawą, a pozostałe grzecznie będą czekały.
Powyższy kod sprawdziłem w swojej aplikacji gdzie przy wejściu na jeden z widoków od razu pobieram wartości domyślne dla dwóch dropdownów.
Krótki czas życia tokena nie jest żadnym zabezpieczeniem.
1. Kilka minut może wystarczyc żeby wykraść dane.
2. Jeśli ktoś potrafi przejąć token główny to może też przejąć token do odświeżania.
IMO cały koncept krótkiego czasu życia i odświeżania tokena powstał z innego powodu. Mianowicie żeby umożliwić inwalidację tokena np. Kiedy administrator zablokuje konto użytkownika.
Inzynier, ale przeciez admin rownie dobrze moglby inwalidowac glowny (access) token.
Nie może ponieważ do inwalidacji tokena potrzebne jest połączenie z bazą.
Cytując artykuł:
Jeśli używasz JWT nie musisz odpytywać bazy przy każdym requeście. Żeby uwierzytelnić użytkownika wystarczy zweryfikować podpis tokena JWT. Oczywiście można odpytywać bazę przy każdym requeście, ale tracimy w ten sposób jedną z zalet JWT.
Polecam też:
https://security.stackexchange.com/questions/119371/is-refreshing-an-expired-jwt-token-a-good-strategy