Strona główna / Artykuły / Dlaczego Concurrent 401s wylogowuje użytkowników oraz jak naprawić problem odświeżania jednoczesnego lotu

Dlaczego Concurrent 401s wylogowuje użytkowników oraz jak naprawić problem odświeżania jednoczesnego lotu

Jak równoległe żądania oraz rotacja tokenów odświeżania powodują niespodziewane wylogowania, oraz jak jedna wspólna obietnica odświeżenia w intercepcie Axios temu zapobiega.

1371 słów

Użytkownik jest w połowie długiej formularza, gdy aplikacja odsyła go z powrotem na ekran logowania. Nie ma błędu ani awarii, a wszystko, co wpisał, znika. Logika wygaśnięcia tokena wydaje się poprawna, proces odnowienia również, a mimo to sesje ciągle kończą się zbyt wcześnie. Poniżej zobaczysz, dlaczego tak się dzieje, gdy kilka żądań trafia do wygasłego tokena w tym samym momencie, dlaczego błąd jest tak trudny do wykrycia przy czytaniu kodu, oraz jak niewielka zmiana w intercepcie Axios, polegająca na współdzieleniu jednej obietnicy odnowienia w trakcie przetwarzania żądania, sprawia, że znika on.

Symptom: logowania wylogowujące użytkownika, które powinny być niemożliwe

Załóżmy platformę do zarządzania przypadkami używaną przez pracowników terenowych. Funkcjonariusze wprowadzają dane z inspekcji lub badań na tabletach, często przez niewiarygodne połączenia mobilne. Nadchodzi raport, a potem kolejny od innego użytkownika: aplikacja wylogowała go podczas wypełniania formularza. W takim systemie to nie jest drobna irytacja – może oznaczać konieczność ponownego wprowadzania danych przez dwadzieścia minut.

Oczywistym podejrzanym jest wygaśnięcie tokena, a na pierwszy rzut oka wydaje się ono niewinne. Tokeny dostępu trwają 15 minut. Gdy wywołanie API zwraca 401, klient wywołuje punkt końcowy odświeżania, przechowuje nowy token i kontynuuje pracę. Nic w tym opisie nie powinno zakończać sesji w trakcie jej używania. Gdy oczywiste wyjaśnienie się sprawdza, właściwym krokiem jest przestać ufać własnemu interpretowaniu kodu i odtworzyć błąd.

Odtwarzanie problemu związанego z wygaśnięciem tokena

Błąd pojawia się tylko w jednej sytuacji: kilka wywołań API następuje bardzo blisko siebie, dokładnie w momencie wygaśnięcia tokena dostępu. Formularz z kilkoma sekcjami jest idealnym czynnikiem wyzwalającym – może wysłać prośbę o weryfikację, prośbę o automatyczne zapisywanie oraz sprawdzenie stanu przesyłania pliku w ciągu zaledwie kilku milisekund. Jeśli token wygaśnie w tym oknie czasowym, wszystkie te próby zwracają niemal jednocześnie kod 401.

Interceptor został napisany dla przypadku pojedynczego wywołania: w przypadku błędu 401 należy wywołać endpoint odświeżania, otrzymać nowy token i ponownie wysłać pierwotną prośbę. Przy testowaniu pojedynczych wywołań działa doskonale. Jednak gdy cztery wywołania zawiodą jednocześnie, uruchamia się cztery niezależne prośby o odświeżenie.

To koliduje z rozsądną decyzją strony serwerowej: rotacją tokenów odświeżania. Gdy użyty zostanie token odświeżania, serwer unieważnia go i wydaje nowy, dzięki czemu skradziony token nie może być używany w nieskończoność. Teraz przyjrzyjmy się czterem równoległym próbom odświeżenia:

  • Pierwsza prośba o odświeżenie udaje się i otrzymuje nowy token odświeżania.
  • Druga, trzecia i czwarta próba zostały już wysłane z użyciem starego tokena odświeżania, zanim nadeszła pierwsza odpowiedź.
  • Serwer odrzuca je, ponieważ ten token właśnie został unieważniony.
  • Interceptor interpretuje nieudane odświeżenie jako „sesja rzeczywiście się zakończyła” i wylogowuje użytkownika.

Czas trwania tokena dostępu nigdy nie był problemem. Błędne założenie polegało na tym, że odświeżanie tokenów nigdy nie zachodzi jednocześnie. Wiele implementacji mechanizmu rotacji idzie jeszcze dalej i traktuje ponowne użycie starego tokena odświeżania jako oznakę kradzieży, co skutkuje anulowaniem całej rodziny tokenów, co zamienia ten sam problem w jeszcze bardziej agresywne wymuszanie wylogowania.

Dlaczego analiza kodu tego nie ujawniła

To jest chyba cenniejsza lekcja niż sama poprawka. Czytając od góry do dołu, interceptor działa prawidłowo: łapie błąd 401, odświeża token i próbuje ponownie. Taka jest sekwencja, dla której został napisany, a ponowne przeczytanie potwierdza jedynie tę sekwencję.

To, co ukrywa analiza kodu, to fakt, że interceptor nie jest wykonywany raz. Jest wykonywany za każdym razem, gdy wystąpi nieudana próba żądania, a te próby zachodzą jednocześnie, a nie sekwencyjnie. Debugowanie go jakby był to skrypt liniowy oznacza rozważanie tego, co robi każdy krok, ignorując przy tym moment jego wykonywania.

Wzorzec staje się widoczny niemal natychmiast po zapisaniu daty i godziny przy każdym wywołaniu odświeżenia. W takim scenariuszu zobaczysz cztery próby odświeżenia w ciągu około 40 milisekund od siebie, wszystkie skierowane do tego samego punktu końcowego. Godzinę spędzoną na analizie logiki można zastąpić dwiema minutami obserwacji czasu trwania operacji. Gdy błąd „nie może się zdarzyć” według kodu, monitorowanie kolejności i czasu występowania zdarzeń jest często szybsze niż ponowna lektura kodu.

Rozwiązanie: jedno odświeżenie w trakcie, wszyscy pozostali czekają

Gdy już stanie się jasny rzeczywisty charakter problemu, rozwiązanie jest proste. Zamiast pozwalać każdemu błędowi 401 na uruchomienie własnego odświeżenia, interceptor sprawdza, czy odświeżanie już trwa. Jeśli tak, nowy błąd nie uruchamia kolejnego – czeka na tę samą obietnicę w toku i próbuje ponownie, gdy ta się spełni. Nazywa się to często wzorcem pojedynczego lotu.

Pierwszym elementem jest zmienna na poziomie modułu, która przechowuje trwające odświeżanie lub null, gdy żadne nie jest w toku:

let refreshPromise = null;

Przetwarzacz poniżej jest wywoływany w przypadku żądania, które zakończyło się błędem 401. Jeśli refreshPromise jest pusty, wywołuje się refreshAccessToken() i przechowuje powstałą obietnicę, do której dodawany jest blok finally służący do przywrócenia zmiennej do wartości null niezależnie od tego, czy odświeżenie się udało, czy nie – aby kolejny termin ważności mógł uruchomić nową próbę. Każdy wywołujący, zarówno pierwszy, jak i wszystkie późniejsze, oczekuje na tę samą obietnicę, ustawia nowy token nośny w oryginalnej konfiguracji żądania i ponownie wysyła żądanie za pośrednictwem axios.

async function handleUnauthorized(originalRequest) {
  if (!refreshPromise) {
    refreshPromise = refreshAccessToken().finally(() => {
      refreshPromise = null;
    });
  }  const newToken = await refreshPromise;
  originalRequest.headers.Authorization = `Bearer ${newToken}`;
  return axios(originalRequest);
}

Syntakta ma mniejsze znaczenie niż sama koncepcja: jedna wspólna obietnica, na której czeka każda nieudana prośba, zamiast tego, by każda z nich próbowała odświeżyć dane osobno. Pierwszy kod 401 uruchamia proces odświeżania; każdy kolejny kod 401 otrzymany w trakcie tego procesu korzysta z już uzyskanego wyniku, zamiast ponownie używać tokena odświeżania. Ponieważ JavaScript wykonywa ten kod na pojedynczej nitce, sprawdzenie i przypisanie wartości refreshPromise nie może odbywać się równolegle pomiędzy różnymi prośbami, co sprawia, że taka prosta ochrona jest wystarczająca.

Istnieje kilka szczegółów, które należy uwzględnić podczas integracji tego rozwiązania z rzeczywistym interceptorem:

  • Jeśli sam proces odświeżania się nie powiedzie, wszystkie czekające prośby odrzucą żądanie jednocześnie, dzięki czemu aplikacja może przeprowadzić jeden poprawny proces wylogowania zamiast kilku konkurencyjnych.
  • Mark ponawiał żądania (na przykład za pomocą flagi _retry w konfiguracji), aby żądanie, które ponownie zwraca błąd 401 po odświeżeniu, nie krążyło w nieskończoność.
  • Należy upewnić się, że samo żądanie odświeżenia omija ten obsługujący element, w przeciwnym razie błąd 401 z punktu końcowego odświeżania spróbuje ponownie wykonać odświeżenie.
  • Każda osobna karta w przeglądarce ma swój własny kontekst JavaScript, więc mogą one nadal ze sobą konkurować; jeśli ma to znaczenie dla Twojej aplikacji, koordynuj działania między kartami lub odświeżaj je proaktywnie przed upływem terminu ważności.
  • Jeśli chodzi o stronę serwera tego samego rozwiązania, w tym rotację i anulowanie tokenów, zapoznaj się z naszym przewodnikiem na temat strategii tokenów odświeżania dla systemów autoryzacji w Node.js.

    Główne wnioski

    Po wdrożeniu aktualizacji jednorazowego odświeżenia problemy z niespodziewanym wylogowaniem się ustają. Szerszą zmianą jest nowy nawyk: traktowanie połączeń równoczesnych jako standardu, a nie przypadków szczególnych do rozwiązania później. Zawsze, gdy piszesz interceptor, kolejkę lub jakikolwiek mechanizm obsługi reagujący na asynchroniczną awarię, zastanów się, co się stanie, jeśli zostanie on uruchomiony cztery razy w oknie czasowym pięćdziesięciu milisekund. Ruch produkcyjny pochodzący od intensywnie używanych formularzy oraz niestabilnych połączeń mobilnych prędzej czy później doprowadzi do takiej sytuacji.

    • Błąd nie dotyczył tak naprawdę tokenów odświeżania; chodziło o kod, który zakładał, że wydarzenia zachodzą pojedynczo.
    • Rotacja tokenów odświeżania to dobra praktyka bezpieczeństwa, ale właśnie ona ujawnia duplikatowe żądania odświeżenia.
    • Kod, który wydaje się poprawny przy czytaniu linijka po linijce, może nadal zawieść w warunkach równoczesności; zapisuj daty i godziny, aby sprawdzić, kiedy coś się dzieje, a nie tylko to, co się dzieje.
  • Użyj jednej obietnicy odświeżenia w trakcie lotu dla wszystkich nieudanych żądań, zresetuj ją w bloku finally i chronij się przed pętlami ponawiania prób.
  • Literatura pokrewna