Startseite / Artikel / Warum Concurrent 401s Benutzer ausloggen und die Lösung durch ein einmaliges Aktualisieren

Warum Concurrent 401s Benutzer ausloggen und die Lösung durch ein einmaliges Aktualisieren

Wie parallele Anfragen und die Rotation von Refresh-Tokens zu unerwarteten Abmeldungen führen können und wie eine gemeinsame Refresh-Promise in einem Axios-Interceptor dies verhindert.

1371 Wörter

Ein Benutzer ist mitten in einem langen Formular, als die App ihn wieder auf den Anmeldescreen zurückführt. Es tritt weder ein Fehler noch ein Absturz auf, und alles, was er eingegeben hat, ist verschwunden. Die Logik zur Ablaufzeit des Tokens scheint korrekt zu sein, der Erneuerungsablauf ebenfalls, dennoch enden die Sitzungen ständig zu früh. Im Folgenden erfahren Sie, warum das passiert, wenn mehrere Anfragen gleichzeitig auf ein abgelaufenes Token stoßen, warum der Fehler durch das Lesen des Codes so schwer zu erkennen ist und wie eine kleine Änderung an einem Axios-Interceptor, bei der ein einziger Erneuerungsversprechen geteilt wird, dieses Problem beseitigt.

Das Symptom: Anmeldungen, die eigentlich unmöglich sein sollten

Stellen Sie sich eine Fallverwaltungsplattform vor, die von Feldmitarbeitern genutzt wird. Die Beamten eingeben Inspektions- oder Prüfungsdaten auf Tablets ein, oft über unzuverlässige Mobilverbindungen. Dann kommt ein Bericht herein, anschließend ein weiterer von einem anderen Benutzer: Die App hat ihn ausgeloggt, während er ein Formular ausfüllte. In einem solchen System ist das keine kleine Störung – es kann bedeuten, dass zwanzig Minuten sorgfältiger Dateneingabe erneut aufgewendet werden müssen.

Der offensichtliche Verdächtige ist die Ablaufzeit der Zugriffstoken, und auf den ersten Blick scheint das unschuldig zu sein. Zugriffstoken gelten 15 Minuten lang. Wenn eine API-Anfrage 401 zurückgibt, ruft der Client den Aktualisierungs-Endpunkt auf, speichert das neue Token und setzt die Arbeit fort. In dieser Beschreibung sollte nichts dazu führen, dass eine Sitzung mitten in der Nutzung beendet wird. Wenn die offensichtliche Erklärung zutrifft, ist der sinnvolle Schritt, dem eigenen Verständnis des Codes nicht mehr zu trauen und den Fehler nachzuvollziehen.

Nachvollziehen eines Konflikts aufgrund der Ablaufzeit der Zugriffstoken

Dieser Fehler tritt nur unter einer Bedingung auf: Mehrere API-Aufrufe werden kurz nacheinander gestartet, genau zu dem Zeitpunkt, an dem das Zugriffstoken abläuft. Ein mehrteiliges Formular ist hierfür ein perfekter Auslöser – es kann innerhalb weniger Millisekunden eine Validierungsanfrage, eine Automatisierungsspeicheranfrage sowie eine Überprüfung des Datei-Upload-Zustands senden. Wenn das Token in diesem Zeitraum abläuft, kommen fast gleichzeitig alle Anfragen mit einem 401-Status zurück.

Der Interceptor wurde für den Fall eines einzelnen Anfragesatzes entwickelt: Bei einem 401-Status wird das Erneuerungs-Endpunkt aufgerufen, ein neues Token erhalten und anschließend die ursprüngliche Anfrage erneut gesendet. Bei getesteten einzelnen Anfragen funktioniert dies einwandfrei. Wenn jedoch vier Anfragen gleichzeitig fehlschlagen, werden sofort vier unabhängige Erneuerungsanfragen gestartet.

Dies steht im Widerspruch zu einer vernünftigen Entscheidung auf der Backend-Seite: der Rotation von Refresh-Tokens. Wenn ein Refresh-Token verwendet wird, macht der Server es ungültig und gibt ein neues aus, sodass ein gestohlenes Refresh-Token nicht endlos wiederverwendet werden kann. Betrachten wir nun die vier parallelen Refresh-Anfragen:

  • Die erste Refresh-Anfrage ist erfolgreich und erhält ein neues Refresh-Token.
  • Die zweite, dritte und vierte Anfrage wurden bereits mit dem alten Refresh-Token gesendet, bevor die erste Antwort eintraf.
  • Der Server lehnt sie ab, weil dieses Token gerade ungültig gemacht wurde.
  • Der Interceptor interpretiert einen fehlgeschlagenen Refresh als „die Sitzung ist tatsächlich beendet“ und meldet den Benutzer ab.

Die Gültigkeitsdauer des Zugriffstokens war nie das Problem. Die falsche Annahme bestand darin, dass Aufrufe zum Erneuern des Tokens niemals gleichzeitig stattfinden. Viele Implementierungen gehen noch weiter und betrachten die Wiederverwendung eines alten Erneuerungstokens als Anzeichen für Diebstahl, wodurch die gesamte Tokenfamilie widerrufen wird – was denselben Konflikt zu einem noch aggressiveren Abmeldevorgang macht.

Warum das Lesen des Codes es nicht aufzeigte

Das ist wohl eine wertvollere Lektion als die eigentliche Korrektur. Wenn man den Code von oben nach unten liest, ist der Interceptor korrekt implementiert: Er fängt den 401-Fehler ab, erneuert das Token und versucht es erneut. Das ist die Sequenz, für die er geschrieben wurde, und ein erneutes Lesen bestätigt lediglich diese Sequenz.

Was das Lesen des Codes jedoch verbirgt, ist, dass der Interceptor nicht nur einmal ausgeführt wird. Er wird bei jedem fehlgeschlagenen Anfrageversuch ausgeführt, und diese Anfragen finden gleichzeitig statt, nicht nacheinander. Wenn man ihn wie einen linearen Script behandelt, versucht man, zu verstehen, was jeder Schritt tut, ohne zu berücksichtigen, wann dieser Schritt stattfindet.

Sobald Sie für jeden Aufruf zur Aktualisierung einen Zeitstempel protokollieren, wird das Muster fast sofort sichtbar. In einem solchen Szenario würden Sie vier Aktualisierungsversuche innerhalb von etwa 40 Millisekunden voneinander sehen, alle gerichtet auf denselben Endpunkt. Eine Stunde des Nachdenkens über die Logik kann durch zwei Minuten des Beobachtens der Zeitabläufe ersetzt werden. Wenn ein Fehler laut Code „nicht auftreten kann“, ist es oft schneller, die Reihenfolge und den Zeitpunkt der Ereignisse zu überwachen, als den Code erneut durchzulesen.

Die Lösung: Eine Aktualisierung läuft, alle anderen warten

Sobald die wahre Natur des Problems klar ist, ist die Lösung einfach. Anstatt jedes 401-Fehler zu einem eigenen Neuladen zu veranlassen, prüft der Interceptor, ob bereits ein Neuladen im Gange ist. Falls ja, führt der neue Fehler kein weiteres Neuladen aus; stattdessen wartet er auf dieselbe ausstehende Promise und versucht es erneut, sobald diese abgeschlossen ist. Dies wird oft als Single-Flight-Muster bezeichnet.

Der erste Bestandteil ist eine modulweite Variable, die das laufende Neuladen enthält – oder null, wenn kein Neuladen stattfindet:

let refreshPromise = null;

Der untenstehende Handler wird für eine Anfrage aufgerufen, die mit 401 fehlgeschlagen ist. Wenn refreshPromise leer ist, ruft er refreshAccessToken() auf und speichert die resultierende Promise, wobei ein finally-Block hinzugefügt wird, der die Variable unabhängig davon auf null zurücksetzt, ob das Erneuern erfolgreich war oder nicht, damit ein neuer Versuch beim nächsten Ablaufzeitpunkt ausgelöst werden kann. Jeder Aufrufer, der erste sowie alle nachfolgenden, wartet anschließend auf dieselbe Promise, setzt den neuen Zugangstoken in die ursprüngliche Anfragekonfiguration und sendet die Anfrage erneut über axios.

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

Die Syntax ist weniger wichtig als die Idee: eine einzige gemeinsame Promise, auf die alle fehlgeschlagenen Anfragen warten, anstatt dass jede von ihnen unabhängig nach einem Neuladen versucht. Der erste 401-Status startet das Neuladen; jeder weitere 401-Status, der während dieses Vorgangs eintrifft, nutzt einfach das bereits vorhandene Ergebnis, anstatt erneut einen Refresh-Token zu verwenden. Da JavaScript diesen Code auf einem einzigen Thread ausführt, können die Überprüfung und Zuweisung von refreshPromise nicht zwischen den Aufrufen abwechseln – genau das macht eine solch einfache Schutzmaßnahme ausreichend.

Es gibt einige Details, die berücksichtigt werden sollten, wenn man dies in einen echten Interceptor integriert:

  • Falls das Neuladen selbst fehlschlägt, werden alle wartenden Anfragen gemeinsam abgelehnt, sodass die App einen sauberen Abmeldevorgang durchführen kann, anstatt mehrere konkurrierende Vorgänge.
  • Mark wiederholte Anfragen (zum Beispiel mit einem _retry-Flag in der Konfiguration), damit eine Anfrage, die beim Erneuern erneut mit 401 fehlschlägt, nicht endlos im Kreis läuft.
  • Sorgen Sie dafür, dass die Erneuerungsanfrage selbst diesen Handler umgeht – andernfalls wird ein 401-Fehler vom Erneuerungsendpunkt versuchen, sich selbst erneut zu aktualisieren.
  • Jede separate Browserleiste hat ihren eigenen JavaScript-Kontext, wodurch sie weiterhin miteinander konkurrieren können; falls das für Ihre Anwendung relevant ist, koordinieren Sie die Abläufe zwischen den Leisten oder erneuern Sie proaktiv vor Ablauf der Gültigkeit.
  • Für die Serverseite desselben Designs, einschließlich Rotation und Revokation, sehen Sie unseren Leitfaden zur Refresh-Token-Strategie für Node.js-Authentifizierungssysteme.

    Haupterkenntnisse

    Durch den Einzelflug-Refresh hören die unerwarteten Abmeldungen auf. Die grundsätzliche Änderung ist eine Gewohnheit: Konkurrierende Aufrufe als Standardfall betrachten und nicht als Sonderfall, der später behandelt werden muss. Immer wenn Sie einen Interceptor, eine Warteschlange oder irgendeinen Handler schreiben, der auf asynchrone Fehler reagiert, fragen Sie sich, was passiert, wenn er innerhalb eines Zeitraums von fünfzig Millisekunden viermal ausgeführt wird. Produktivverkehr durch stark genutzte Formulare sowie instabile Mobilverbindungen wird diese Situation früher oder später hervorrufen.

    • Der Fehler betraf eigentlich nicht die Refresh-Tokens; es ging um Code, der davon ausging, dass Ereignisse nacheinander stattfinden.
    • Die Rotation der Refresh-Tokens ist eine gute Sicherheitspraxis – und genau sie macht doppelte Refresh-Anfragen sichtbar.
    • Code, der Zeile für Zeile korrekt erscheint, kann dennoch bei Konkurrenzversuchen fehlschlagen; protokollieren Sie Zeitstempel, um zu sehen, wann etwas passiert – nicht nur was.
  • Teilen Sie ein gemeinsames Versprechen zur Aktualisierung während des Fluges allen fehlerhaften Anfragen mit, setzen Sie es in finally zurück und schützen Sie sich vor Wiederholungszyklen.
  • Verwandte Artikel