React Token Auth


Problemm


Die Autorisierung ist eines der ersten Probleme, mit denen Entwickler beim Starten eines neuen Projekts konfrontiert sind. Und eine der häufigsten Arten der Autorisierung (aus meiner Erfahrung) ist die tokenbasierte Autorisierung (normalerweise mit JWT).


Aus meiner Sicht sieht dieser Artikel aus wie "das, was ich vor zwei Wochen lesen wollte". Mein Ziel war es, minimalistischen und wiederverwendbaren Code mit einer sauberen und übersichtlichen Oberfläche zu schreiben. Ich hatte die nächsten Anforderungen für meine Implementierung des Authentifizierungsmanagements:


  • Token sollten lokal aufbewahrt werden
  • Token sollten beim Neuladen der Seite wiederhergestellt werden
  • Das Zugriffstoken sollte in den Netzwerkanforderungen übergeben werden
  • Nach Ablauf sollte das Zugriffstoken durch ein Aktualisierungstoken aktualisiert werden, wenn das letzte angezeigt wird
  • Reaktionskomponenten sollten Zugriff auf die Authentifizierungsinformationen haben, um die entsprechende Benutzeroberfläche zu rendern
  • Die Lösung sollte mit Pure React hergestellt werden (ohne Redux, Thunk, etc ..)

Für mich war eine der herausforderndsten Fragen:


  • So bleiben Sie synchron Reagieren Sie auf den Status der Komponenten und die lokalen Speicherdaten?
  • So erhalten Sie das Token in Fetch, ohne es durch den gesamten Elementbaum zu führen (insbesondere, wenn Sie dieses Fetch beispielsweise später in Thunk-Aktionen verwenden möchten)

Aber lassen Sie uns die Probleme Schritt für Schritt lösen. Zunächst erstellen wir einen token provider zum Speichern von Token und zum Abhören von Änderungen. Danach erstellen wir einen auth provider , der den auth provider tatsächlich umschließt, um Hooks für React-Komponenten zu erstellen, Steroide abzurufen und einige zusätzliche Methoden. Und am Ende werden wir uns ansehen, wie diese Lösung im Projekt verwendet wird.


Ich möchte nur npm install ... und in Produktion gehen


Ich habe bereits das Paket zusammengestellt, das alle unten beschriebenen (und ein bisschen mehr) enthält. Sie müssen es nur mit dem folgenden Befehl installieren:


 npm install react-token-auth 

Und folgen Sie den Beispielen im GitHub-Repository " react-token-auth" .


Lösung


Bevor ich das Problem löse, gehe ich davon aus, dass es ein Backend gibt, das ein Objekt mit Zugriffs- und Aktualisierungstoken zurückgibt. Jedes Token hat ein JWT Format. Ein solches Objekt könnte so aussehen:


 { "accessToken": "...", "refreshToken": "..." } 

Tatsächlich ist die Struktur des Token-Objekts für uns nicht kritisch. Im einfachsten Fall kann es sich um eine Zeichenfolge mit einem unbegrenzten Zugriffstoken handeln. Wir möchten jedoch untersuchen, wie Sie mit einer Situation umgehen, in der zwei Token vorhanden sind, von denen einer möglicherweise abläuft und der zweite möglicherweise zum Aktualisieren des ersten verwendet wird.


Jwt


Wenn Sie nicht wissen, was das JWT-Token ist, gehen Sie am besten zu jwt.io und sehen Sie sich an, wie es funktioniert. Jetzt ist es wichtig, dass das JWT-Token codierte Informationen (im Base64 Format) über den Benutzer enthält, mit denen er auf dem Server authentifiziert werden kann.


Normalerweise enthält das JWT-Token 3 Teile, die durch Punkte unterteilt sind und wie folgt aussehen:


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyMn0.yOZC0rjfSopcpJ-d3BWE8-BkoLR_SCqPdJpq8Wn-1Mc


Wenn wir den mittleren Teil ( eyJu...Mn0 ) dieses Tokens dekodieren, erhalten wir den nächsten JSON:


 { "name": "John Doe", "iat": 1516239022, "exp": 1516239022 } 

Mit diesen Informationen können wir das Ablaufdatum des Tokens ermitteln.


Token-Anbieter


Wie bereits erwähnt, erstellen wir zunächst den Token-Provider. Der Token-Anbieter arbeitet direkt mit dem lokalen Speicher und allen Änderungen des Tokens, die wir dadurch vornehmen. Es wird uns ermöglichen, Änderungen von überall zu hören und die Hörer sofort über Änderungen zu informieren (aber etwas später). Die Schnittstelle des Providers wird die folgenden Methoden haben:


  • getToken() zum getToken() des aktuellen Tokens (wird beim Abrufen verwendet)
  • setToken() zum Setzen des Tokens nach dem Anmelden, Abmelden oder Registrieren
  • isLoggedIn() , um zu überprüfen, ob der Benutzer angemeldet ist
  • subscribe() , um dem Provider eine Funktion zu geben, die nach jeder Tokenänderung aufgerufen werden soll
  • unsubscribe() , um den Abonnenten zu entfernen

Die Funktion createTokenProvider() erstellt eine Instanz des Token-Providers mit der beschriebenen Schnittstelle:


 const createTokenProvider = () => { /* Implementation */ return { getToken, isLoggedIn, setToken, subscribe, unsubscribe, }; }; 

Der gesamte nächste Code sollte sich in der Funktion createTokenProvider befinden.


Beginnen wir mit der Erstellung einer Variablen zum Speichern von Token und zum Wiederherstellen der Daten aus dem lokalen Speicher (um sicherzustellen, dass die Sitzung nach dem erneuten Laden der Seite nicht verloren geht):


 let _token: { accessToken: string, refreshToken: string } = JSON.parse(localStorage.getItem('REACT_TOKEN_AUTH') || '') || null; 

Jetzt müssen wir einige zusätzliche Funktionen erstellen, um mit JWT-Token arbeiten zu können. Im Moment sieht das JWT-Token wie eine magische Zeichenfolge aus, aber es ist keine große Sache, es zu analysieren und zu versuchen, das Ablaufdatum zu extrahieren. Die Funktion getExpirationDate() nimmt ein JWT-Token als Parameter und gibt bei Erfolg einen Zeitstempel für das Ablaufdatum zurück (oder null bei einem Fehler):


 const getExpirationDate = (jwtToken?: string): number | null => { if (!jwtToken) { return null; } const jwt = JSON.parse(atob(jwtToken.split('.')[1])); // multiply by 1000 to convert seconds into milliseconds return jwt && jwt.exp && jwt.exp * 1000 || null; }; 

Und eine weitere util-Funktion isExpired() prüft, ob der Zeitstempel abgelaufen ist. Diese Funktion gibt true zurück, wenn der angegebene Date.now() kleiner als Date.now() .


 const isExpired = (exp?: number) => { if (!exp) { return false; } return Date.now() > exp; }; 

Zeit zum Erstellen der ersten Funktion der Token-Provider-Schnittstelle. Die Funktion getToken() sollte das Token zurückgeben und bei Bedarf aktualisieren. Diese Funktion sollte async da möglicherweise eine Netzwerkanforderung zum Aktualisieren des Tokens gesendet wird.


Mit den zuvor erstellten Funktionen können wir überprüfen, ob die Zugriffstoken abgelaufen sind oder nicht ( isExpired(getExpirationDate(_token.accessToken)) ). Und im ersten Fall eine Anforderung zum Aktualisieren des Tokens. Danach können wir Tokens speichern (mit der noch nicht implementierten Funktion setToken() ). Und schließlich können wir das Zugriffstoken zurückgeben:


 const getToken = async () => { if (!_token) { return null; } if (isExpired(getExpirationDate(_token.accessToken))) { const updatedToken = await fetch('/update-token', { method: 'POST', body: _token.refreshToken }) .then(r => r.json()); setToken(updatedToken); } return _token && _token.accessToken; }; 

Die Funktion isLoggedIn() ist einfach: Sie gibt true zurück, wenn _tokens nicht null und prüft nicht, ob das Zugriffstoken _tokens ist (in diesem Fall wissen wir nicht, ob das _tokens ist, bis es beim _tokens Tokens fehlschlägt. In der Regel ist dies jedoch ausreichend , und lassen Sie uns die Funktion isLoggedIn synchron halten):


 const isLoggedIn = () => { return !!_token; }; 

Ich denke, es ist ein guter Zeitpunkt, um Funktionen für die Verwaltung von Beobachtern zu erstellen. Wir werden etwas Ähnliches wie das Observer-Muster implementieren und zunächst ein Array erstellen, in dem alle unsere Beobachter gespeichert werden. Wir erwarten, dass jedes Element in diesem Array die Funktion ist, die wir nach jeder Änderung von Token aufrufen sollten:


 let observers: Array<(isLogged: boolean) => void> = []; 

Jetzt können wir Methoden subscribe() und unsubscribe() erstellen. Der erste fügt dem etwas früher erstellten Array einen neuen Beobachter hinzu, der zweite entfernt den Beobachter aus der Liste.


 const subscribe = (observer: (isLogged: boolean) => void) => { observers.push(observer); }; const unsubscribe = (observer: (isLogged: boolean) => void) => { observers = observers.filter(_observer => _observer !== observer); }; 

Sie können bereits auf der Oberfläche der Funktionen subscribe() und unsubscribe() dass wir nur die Tatsache an Beobachter senden, dass der Benutzer angemeldet ist. Im Allgemeinen können Sie jedoch alles senden, was Sie möchten (das gesamte Token, die Ablaufzeit usw.). Für unsere Zwecke wird es jedoch ausreichen, eine Boolesche Flagge zu senden.


Lassen Sie uns eine kleine util-Funktion notify notify() erstellen, die dieses Flag entgegennimmt und an alle Beobachter sendet:


 const notify = () => { const isLogged = isLoggedIn(); observers.forEach(observer => observer(isLogged)); }; 

Und zu setToken() Letzt müssen wir noch das setToken() implementieren. Der Zweck dieser Funktion ist das Speichern von Tokens im lokalen Speicher (oder das Bereinigen des lokalen Speichers, wenn das Token leer ist) und das Benachrichtigen von Beobachtern über Änderungen. Also, ich sehe das Ziel, ich gehe zum Ziel.


 const setToken = (token: typeof _token) => { if (token) { localStorage.setItem('REACT_TOKEN_AUTH', JSON.stringify(token)); } else { localStorage.removeItem('REACT_TOKEN_AUTH'); } _token = token; notify(); }; 

Stellen Sie sicher, dass Sie mich schon glücklicher gemacht haben, wenn Sie an diesen Punkt im Artikel gekommen sind und ihn nützlich fanden. Hier beenden wir mit dem Token-Provider. Sie können sich Ihren Code ansehen, damit spielen und prüfen, ob er funktioniert. Darüber hinaus werden wir im nächsten Teil weitere abstrakte Funktionen erstellen, die in jeder React-Anwendung bereits nützlich sind.


Authentifizierungsanbieter


Erstellen wir eine neue Klasse von Objekten, die wir als Auth-Provider aufrufen. Die Schnittstelle enthält 4 Methoden: hook useAuth() , um den aktuellen Status von der React-Komponente zu erhalten, authFetch() , um Anforderungen an das Netzwerk mit dem tatsächlichen Token und den Methoden login() und authFetch() zu authFetch() , die Proxy-Aufrufe an die Methode setToken() des Token-Anbieters (in diesem Fall haben wir nur einen Einstiegspunkt für die gesamte erstellte Funktionalität, und der Rest des Codes muss nichts über das Vorhandensein des Token-Anbieters wissen). Nach wie vor gehen wir vom Funktionsersteller aus:


 export const createAuthProvider = () => { /* Implementation */ return { useAuth, authFetch, login, logout } }; 

Wenn wir einen Token-Provider verwenden möchten, müssen wir zunächst eine Instanz davon erstellen:


 const tokenProvider = createTokenProvider(); 

Die Methoden login() und logout() übergeben das Token einfach an den Token-Provider. Ich habe diese Methoden nur aus Gründen der expliziten Bedeutung getrennt (tatsächlich werden durch das Übergeben von empty / null Token Daten aus dem lokalen Speicher entfernt):


 const login: typeof tokenProvider.setToken = (newTokens) => { tokenProvider.setToken(newTokens); }; const logout = () => { tokenProvider.setToken(null); }; 

Der nächste Schritt ist die Abruffunktion. Nach meiner Vorstellung sollte diese Funktion genau die gleiche Schnittstelle haben wie der ursprüngliche Abruf und dasselbe Format zurückgeben, aber Zugriffstoken für jede Anforderung einspeisen.


Die Fetch-Funktion sollte zwei Argumente haben: request info (normalerweise URL) und request init (ein Objekt mit Methode, Body, Headern usw.); und gibt Versprechen für die Antwort zurück:


 const authFetch = async (input: RequestInfo, init?: RequestInit): Promise<Response> => { const token = await tokenProvider.getToken(); init = init || {}; init.headers = { ...init.headers, Authorization: `Bearer ${token}`, }; return fetch(input, init); }; 

Innerhalb der Funktion haben wir zwei Dinge gemacht: ein Token vom Token-Provider per Anweisung await tokenProvider.getToken(); ( getToken enthält bereits die Logik zum Aktualisieren des Tokens nach Ablauf) und zum Einfügen dieses Tokens in den Authorization Header durch die Zeile Authorization: 'Bearer ${token}' . Danach geben wir einfach fetch mit aktualisierten Argumenten zurück.


So können wir bereits den Authentifizierungsanbieter verwenden, um Token zu speichern und sie vom Abruf zu verwenden. Das letzte Problem ist, dass wir nicht auf die Tokenänderungen unserer Komponenten reagieren können. Zeit, es zu lösen.


Wie ich bereits sagte, erstellen wir einen Hook useAuth() , der der Komponente Informationen liefert, useAuth() der Benutzer angemeldet ist oder nicht. Dazu verwenden wir hook useState() , um diese Informationen zu useState() . Dies ist nützlich, da Änderungen in diesem Status dazu führen, dass Komponenten, die diesen Hook verwenden, erneut gerendert werden.


Und wir haben bereits alles vorbereitet, um Änderungen im lokalen Speicher abhören zu können. Eine gebräuchliche Methode, um Änderungen im System mit Hooks abzuhören, ist der Hook useEffect() . Dieser Hook benötigt zwei Argumente: Funktion und Liste der Abhängigkeiten. Die Funktion wird nach dem ersten Aufruf von useEffect und nach Änderungen in der Liste der Abhängigkeiten neu gestartet. In dieser Funktion können wir Änderungen im lokalen Speicher abhören. Aber was wichtig ist, können wir von dieser Funktion zurückkehren ... neue Funktion und diese neue Funktion wird entweder vor dem Neustart der ersten oder nach dem Abmelden der Komponente ausgelöst. In der neuen Funktion können wir das Abhören der Änderungen einstellen und React garantiert, dass diese Funktion ausgelöst wird (zumindest, wenn während dieses Vorgangs keine Ausnahme auftritt). Hört sich etwas kompliziert an, aber sehen Sie sich den Code an:


 const useAuth = () => { const [isLogged, setIsLogged] = useState(tokenProvider.isLoggedIn()); useEffect(() => { const listener = (newIsLogged: boolean) => { setIsLogged(newIsLogged); }; tokenProvider.subscribe(listener); return () => { tokenProvider.unsubscribe(listener); }; }, []); return [isLogged] as [typeof isLogged]; }; 

Und das ist alles. Wir haben gerade einen kompakten und wiederverwendbaren Token-Authentifizierungsspeicher mit einer übersichtlichen API erstellt. Im nächsten Teil werden wir uns einige Anwendungsbeispiele ansehen.


Verwendung


Um das zu verwenden, was wir oben implementiert haben, müssen wir eine Instanz des Authentifizierungsanbieters erstellen. Es gibt uns Zugriff auf die Funktionen useAuth() , authFetch() , login() useAuth() , authFetch() , die sich auf dasselbe Token im lokalen Speicher beziehen (im Allgemeinen hindert nichts Sie daran, unterschiedliche Instanzen des Authentifizierungsanbieters für verschiedene Token zu erstellen, Sie müssen jedoch den Schlüssel parametrisieren, den Sie zum Speichern von Daten im lokalen Speicher verwenden.


 export const {useAuth, authFetch, login, logout} = createAuthProvider(); 

Anmeldeformular


Jetzt können wir die Funktionen nutzen, die wir haben. Beginnen wir mit der Anmeldeformularkomponente. Diese Komponente sollte Eingaben für die Anmeldeinformationen des Benutzers bereitstellen und im internen Status speichern. Nach dem Absenden müssen wir eine Anfrage mit den Zugangsdaten senden, um Token zu erhalten. Hier können wir die Funktion login() , um empfangene Token zu speichern:


 const LoginComponent = () => { const [credentials, setCredentials] = useState({ name: '', password: '' }); const onChange = ({target: {name, value}}: ChangeEvent<HTMLInputElement>) => { setCredentials({...credentials, [name]: value}) }; const onSubmit = (event?: React.FormEvent) => { if (event) { event.preventDefault(); } fetch('/login', { method: 'POST', body: JSON.stringify(credentials) }) .then(r => r.json()) .then(token => login(token)) }; return <form onSubmit={onSubmit}> <input name="name" value={credentials.name} onChange={onChange}/> <input name="password" value={credentials.password} onChange={onChange}/> </form> }; 

Und das ist alles, es ist alles, was wir brauchen, um den Token zu speichern. Danach, wenn ein Token empfangen wird, müssen wir keine zusätzlichen Anstrengungen unternehmen, um es zum Abrufen oder in Komponenten zu bringen, da es bereits innerhalb des Authentifizierungsanbieters implementiert ist.


Anmeldeformular ist ähnlich, es gibt nur Unterschiede in der Anzahl und den Namen der Eingabefelder, daher werde ich es hier weglassen.


Router


Wir können das Routing auch über den Authentifizierungsanbieter implementieren. Angenommen, wir haben zwei Routenpakete: eines für den registrierten Benutzer und eines für den nicht registrierten Benutzer. Um sie aufzuteilen, müssen wir überprüfen, ob wir ein Token im lokalen Speicher haben oder nicht. Hier können wir hook useAuth() :


 export const Router = () => { const [logged] = useAuth(); return <BrowserRouter> <Switch> {!logged && <> <Route path="/register" component={Register}/> <Route path="/login" component={Login}/> <Redirect to="/login"/> </>} {logged && <> <Route path="/dashboard" component={Dashboard} exact/> <Redirect to="/dashboard"/> </>} </Switch> </BrowserRouter>; }; 

Und das Schöne daran, dass es nach Änderungen im lokalen Speicher erneut useAuth wird, da useAuth ein Abonnement für diese Änderungen hat.


Abrufen von Anforderungen


Und dann können wir mit authFetch Daten durch das Token authFetch . Es hat die gleiche Schnittstelle wie fetch. Wenn Sie fetch also bereits im Code verwenden, können Sie es einfach durch authFetch ersetzen:


 const Dashboard = () => { const [posts, setPosts] = useState([]); useEffect(() => { authFetch('/posts') .then(r => r.json()) .then(_posts => setPosts(_posts)) }, []); return <div> {posts.map(post => <div key={post.id}> {post.message} </div>)} </div> }; 

Zusammenfassung


Wir haben es geschafft. Es war eine interessante Reise, aber es hat auch das Ende (vielleicht sogar glücklich).


Wir begannen mit dem Verständnis von Problemen beim Speichern von Autorisierungstoken. Anschließend haben wir eine Lösung implementiert und uns schließlich die Beispiele für die Verwendung in der React-Anwendung angesehen.


Wie ich bereits sagte, finden Sie meine Implementierung auf GitHub in der Bibliothek. Es löst ein etwas allgemeineres Problem und macht keine Annahmen über die Struktur des Objekts mit Token oder über das Aktualisieren des Tokens. Daher müssen Sie einige zusätzliche Argumente angeben. Die Idee der Lösung ist jedoch dieselbe und das Repository enthält auch Anweisungen zu deren Verwendung.


Hier kann ich mich für das Lesen des Artikels bedanken und ich hoffe, es war hilfreich für Sie.

Source: https://habr.com/ru/post/de485764/


All Articles