Bereaksi Token Auth


Problemm


Otorisasi adalah salah satu masalah pertama yang dihadapi pengembang saat memulai proyek baru. Dan salah satu jenis otorisasi yang paling umum (dari pengalaman saya) adalah otorisasi berbasis token (biasanya menggunakan JWT).


Dari sudut pandang saya, artikel ini seperti "apa yang ingin saya baca dua minggu lalu". Tujuan saya adalah menulis kode minimalis dan dapat digunakan kembali dengan antarmuka yang bersih dan mudah. Saya memiliki persyaratan berikutnya untuk implementasi manajemen auth:


  • Token harus disimpan di penyimpanan lokal
  • Token harus dikembalikan pada pemuatan ulang halaman
  • Token akses harus diteruskan dalam permintaan jaringan
  • Setelah kedaluwarsa, token akses harus diperbarui dengan menyegarkan token jika yang terakhir disajikan
  • Komponen yang bereaksi harus memiliki akses ke informasi autentikasi untuk membuat UI yang sesuai
  • Solusinya harus dibuat dengan Bereaksi murni (tanpa Redux, thunk, dll.)

Bagi saya salah satu pertanyaan yang paling menantang adalah:


  • Bagaimana cara menyinkronkan React state status dan data penyimpanan lokal?
  • Cara mendapatkan token di dalam mengambil tanpa melewati seluruh pohon elemen (terutama jika kita ingin menggunakan mengambil ini dalam tindakan thunk nanti misalnya)

Tapi mari kita selesaikan masalah itu selangkah demi selangkah. Pertama, kami akan membuat token provider untuk menyimpan token dan memberikan kemungkinan untuk mendengarkan perubahan. Setelah itu, kami akan membuat auth provider , sebenarnya membungkus token provider untuk membuat kait untuk komponen Bereaksi, mengambil steroid dan beberapa metode tambahan. Dan pada akhirnya, kita akan melihat bagaimana menggunakan solusi ini dalam proyek.


Saya hanya ingin npm install ... dan mulai berproduksi


Saya sudah mengumpulkan paket yang berisi semua yang dijelaskan di bawah ini (dan sedikit lebih banyak). Anda hanya perlu menginstalnya dengan perintah:


 npm install react-token-auth 

Dan ikuti contoh dalam repositori GitHub react-token-auth .


Solusi


Sebelum menyelesaikan masalah, saya akan membuat asumsi bahwa kita memiliki backend yang mengembalikan objek dengan token akses dan refresh. Setiap token memiliki format JWT . Objek seperti itu mungkin terlihat seperti:


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

Sebenarnya, struktur objek token tidak penting bagi kami. Dalam kasus yang paling sederhana, mungkin string dengan token akses tak terbatas. Tetapi kami ingin melihat bagaimana mengelola suatu situasi ketika kami memiliki dua token, salah satunya token, dan yang kedua mungkin digunakan untuk memperbarui yang pertama.


Jwt


Jika Anda tidak tahu apa token JWT, pilihan terbaik adalah pergi ke jwt.io dan lihat bagaimana cara kerjanya. Sekarang penting bahwa token JWT berisi informasi yang disandikan (dalam format Base64 ) tentang pengguna yang memungkinkan otentikasi dia di server.


Biasanya token JWT berisi 3 bagian dibagi dengan titik-titik dan terlihat seperti:


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


Jika kita mendekode bagian tengah ( eyJu...Mn0 ) dari token ini, kita akan mendapatkan JSON berikutnya:


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

Dengan informasi ini, kita akan dapat memperoleh tanggal kedaluwarsa dari token.


Penyedia token


Seperti yang saya sebutkan sebelumnya, langkah pertama kami adalah menciptakan penyedia token. Penyedia token akan bekerja langsung dengan penyimpanan lokal dan semua perubahan token akan kami lakukan melaluinya. Ini akan memungkinkan kami untuk mendengarkan perubahan dari mana saja dan segera memberi tahu pendengar tentang perubahan (tetapi sedikit tentangnya nanti). Antarmuka penyedia akan memiliki metode berikut:


  • getToken() untuk mendapatkan token saat ini (ini akan digunakan dalam pengambilan)
  • setToken() untuk mengatur token setelah login, logout atau registrasi
  • isLoggedIn() untuk memeriksa apakah pengguna masuk
  • subscribe() untuk memberi penyedia fungsi yang harus dipanggil setelah perubahan token apa pun
  • unsubscribe() untuk menghapus pelanggan

Fungsi createTokenProvider() akan membuat instance penyedia token dengan antarmuka yang dijelaskan:


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

Semua kode selanjutnya harus berada di dalam fungsi createTokenProvider.


Mari kita mulai dengan membuat variabel untuk menyimpan token dan mengembalikan data dari penyimpanan lokal (untuk memastikan bahwa sesi tidak akan hilang setelah pemuatan ulang halaman):


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

Sekarang kita perlu membuat beberapa fungsi tambahan untuk bekerja dengan token JWT. Pada saat ini, token JWT terlihat seperti string ajaib, tetapi itu bukan masalah besar untuk menguraikannya dan mencoba untuk mengekstrak tanggal kedaluwarsa. Fungsi getExpirationDate() akan mengambil token JWT sebagai parameter dan mengembalikan timestamp tanggal kedaluwarsa jika berhasil (atau null gagal):


 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; }; 

Dan satu lagi fungsi util isExpired() untuk memeriksa apakah timestamp kadaluarsa. Fungsi ini mengembalikan true jika stempel waktu kedaluwarsa disajikan dan jika kurang dari Date.now() .


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

Saatnya membuat fungsi pertama antarmuka penyedia token. Fungsi getToken() harus mengembalikan token dan memperbaruinya jika perlu. Fungsi ini harus async karena mungkin membuat permintaan jaringan untuk memperbarui token.


Dengan menggunakan fungsi-fungsi yang dibuat sebelumnya, kita dapat memeriksa apakah token akses telah kedaluwarsa atau tidak ( isExpired(getExpirationDate(_token.accessToken)) )). Dan dalam kasus pertama membuat permintaan untuk memperbarui token. Setelah itu, kita dapat menyimpan token (dengan fungsi setToken() belum diimplementasikan). Dan akhirnya, kita dapat mengembalikan token akses:


 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; }; 

Fungsi isLoggedIn() akan menjadi sederhana: itu akan mengembalikan true jika _tokens tidak null dan tidak akan memeriksa akses token kedaluwarsa (dalam hal ini kita tidak akan tahu tentang akses token kadaluarsa sampai kita gagal mendapatkan token, tetapi biasanya itu sudah cukup) , dan biarkan kami menjaga fungsinyaLoggedIn sinkron):


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

Saya pikir ini saat yang tepat untuk membuat fungsionalitas untuk mengelola pengamat. Kami akan menerapkan sesuatu yang mirip dengan pola Observer , dan pertama-tama, akan membuat array untuk menyimpan semua pengamat kami. Kami akan berharap bahwa setiap elemen dalam array ini adalah fungsi yang harus kita panggil setelah setiap perubahan token:


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

Sekarang kita dapat membuat metode subscribe() dan unsubscribe() . Yang pertama akan menambahkan pengamat baru ke array yang dibuat sebelumnya sedikit, yang kedua akan menghapus pengamat dari daftar.


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

Anda sudah dapat melihat dari antarmuka fungsi subscribe() dan unsubscribe() yang akan kami kirim ke pengamat hanya faktanya adalah pengguna yang masuk. Tetapi secara umum, Anda dapat mengirim semua yang Anda inginkan (seluruh token, waktu kedaluwarsa, dll ...). Tetapi untuk tujuan kita, itu akan cukup untuk mengirim bendera boolean.


Mari kita buat fungsi util kecil notify() yang akan mengambil flag ini dan mengirim ke semua pengamat:


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

Dan fungsi terakhir yang tidak kalah penting yang perlu kita terapkan adalah setToken() . Tujuan dari fungsi ini adalah menyimpan token di penyimpanan lokal (atau membersihkan penyimpanan lokal jika token kosong) dan memberi tahu pengamat tentang perubahan. Jadi, saya melihat tujuannya, saya pergi ke tujuan.


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

Pastikan, jika Anda sampai pada titik ini dalam artikel dan merasa bermanfaat, Anda sudah membuat saya lebih bahagia. Di sini kita selesai dengan penyedia token. Anda dapat melihat kode Anda, bermain dengannya dan memeriksa apakah itu berfungsi. Pada bagian selanjutnya di atas ini, kami akan membuat fungsionalitas yang lebih abstrak yang akan berguna dalam aplikasi Bereaksi apa pun.


Penyedia auth


Mari kita buat kelas objek baru yang akan kita sebut sebagai penyedia Auth. Antarmuka akan berisi 4 metode: hook useAuth() untuk mendapatkan status baru dari komponen Bereaksi, authFetch() untuk membuat permintaan ke jaringan dengan token dan login() sebenarnya login() , logout() metode yang akan proksi panggilan ke metode setToken() dari penyedia token (dalam hal ini, kami hanya akan memiliki satu titik masuk ke seluruh fungsi yang dibuat, dan sisa kode tidak perlu tahu tentang keberadaan penyedia token). Seperti sebelumnya kita akan mulai dari pencipta fungsi:


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

Pertama-tama, jika kita ingin menggunakan penyedia token kita perlu membuat instance darinya:


 const tokenProvider = createTokenProvider(); 

Metode login() dan logout() cukup meneruskan token ke penyedia token. Saya memisahkan metode ini hanya untuk makna eksplisit (sebenarnya lewat token kosong / nol menghapus data dari penyimpanan lokal):


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

Langkah selanjutnya adalah fungsi ambil. Menurut ide saya, fungsi ini harus memiliki antarmuka yang sama persis seperti pengambilan asli dan mengembalikan format yang sama tetapi harus menyuntikkan token akses ke setiap permintaan.


Fungsi pengambilan harus mengambil dua argumen: info permintaan (biasanya URL) dan permintaan init (objek dengan metode, isi. Header, dan sebagainya); dan mengembalikan janji untuk respons:


 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); }; 

Di dalam fungsi kami membuat dua hal: mengambil token dari penyedia token dengan pernyataan await tokenProvider.getToken(); ( getToken sudah berisi logika memperbarui token setelah kedaluwarsa) dan menyuntikkan token ini ke header Authorization oleh Authorization: 'Bearer ${token}' baris Authorization: 'Bearer ${token}' . Setelah itu, kami cukup mengembalikan pengambilan dengan argumen yang diperbarui.


Jadi, kita sudah bisa menggunakan penyedia auth untuk menyimpan token dan menggunakannya dari mengambil. Masalah terakhir adalah kita tidak bisa bereaksi terhadap perubahan token dari komponen kita. Saatnya untuk menyelesaikannya.


Seperti yang saya katakan sebelumnya kita akan membuat hook useAuth() yang akan memberikan informasi kepada komponen apakah pengguna login atau tidak. Untuk dapat melakukan itu kita akan menggunakan hook useState() untuk menyimpan informasi ini. Ini berguna karena setiap perubahan dalam status ini akan menyebabkan rerender komponen yang menggunakan kait ini.


Dan kami sudah menyiapkan segalanya untuk dapat mendengarkan perubahan penyimpanan lokal. Cara umum untuk mendengarkan setiap perubahan dalam sistem dengan kait adalah menggunakan hook useEffect() . Hook ini mengambil dua argumen: fungsi dan daftar dependensi. Fungsi ini akan diaktifkan setelah panggilan pertama useEffect dan kemudian diluncurkan kembali setelah ada perubahan dalam daftar dependensi. Dalam fungsi ini, kita dapat mulai mendengarkan perubahan dalam penyimpanan lokal. Tetapi yang penting kita dapat kembali dari fungsi ini ... fungsi baru dan, fungsi baru ini akan diaktifkan baik sebelum meluncurkan kembali yang pertama atau setelah melepas komponen. Dalam fungsi baru, kita dapat berhenti mendengarkan perubahan dan Bereaksi jaminan, bahwa fungsi ini akan diaktifkan (setidaknya jika tidak ada pengecualian terjadi selama proses ini). Kedengarannya agak rumit tetapi lihat saja kodenya:


 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]; }; 

Dan itu saja. Kami baru saja membuat penyimpanan token autentik dan dapat digunakan kembali dengan API yang jelas. Pada bagian selanjutnya, kita akan melihat beberapa contoh penggunaan.


Penggunaan


Untuk mulai menggunakan apa yang kami terapkan di atas, kita perlu membuat instance dari penyedia auth. Ini akan memberi kami akses ke fungsi useAuth() , authFetch() , login() , logout() terkait dengan token yang sama di penyimpanan lokal (secara umum, tidak ada yang mencegah Anda untuk membuat contoh berbeda penyedia auth untuk token yang berbeda, tetapi Anda perlu parametrize kunci yang Anda gunakan untuk menyimpan data di penyimpanan lokal):


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

Formulir masuk


Sekarang kita bisa mulai menggunakan fungsi yang kita dapatkan. Mari kita mulai dengan komponen formulir login. Komponen ini harus memberikan input untuk kredensial pengguna dan menyimpannya dalam kondisi internal. Saat kirim, kami perlu mengirim permintaan dengan kredensial untuk mendapatkan token dan di sini kami dapat menggunakan fungsi login() untuk menyimpan token yang diterima:


 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> }; 

Dan itu saja, itu semua yang kita butuhkan untuk menyimpan token. Setelah itu, ketika token diterima, kita tidak perlu melakukan upaya ekstra untuk membawanya untuk mengambil atau dalam komponen, karena sudah diterapkan di dalam penyedia auth.


Formulir pendaftaran serupa, hanya ada perbedaan dalam jumlah dan nama bidang input, jadi saya akan menghilangkannya di sini.


Router


Selain itu, kami dapat mengimplementasikan perutean menggunakan penyedia auth. Mari kita asumsikan bahwa kita memiliki dua paket rute: satu untuk pengguna terdaftar dan satu untuk tidak terdaftar. Untuk membaginya kita perlu memeriksa apakah kita memiliki token di penyimpanan lokal atau tidak, dan di sini kita dapat menggunakan 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>; }; 

Dan hal yang baik bahwa itu akan dirender ulang setelah ada perubahan dalam penyimpanan lokal, karena useAuth memiliki berlangganan perubahan ini.


Ambil permintaan


Dan kemudian kita bisa mendapatkan data yang dilindungi oleh token menggunakan authFetch . Ini memiliki antarmuka yang sama dengan fetch, jadi jika Anda sudah menggunakan fetch dalam kode, Anda cukup menggantinya dengan authFetch :


 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> }; 

Ringkasan


Kami berhasil. Itu adalah perjalanan yang menarik, tetapi juga memiliki akhir (bahkan mungkin bahagia).


Kami mulai dengan pemahaman tentang masalah dengan menyimpan token otorisasi. Kemudian kami mengimplementasikan solusi dan akhirnya melihat contoh bagaimana mungkin digunakan dalam aplikasi Bereaksi.


Seperti yang saya katakan sebelumnya, Anda dapat menemukan implementasi saya di GitHub di perpustakaan. Ini memecahkan masalah yang sedikit lebih umum dan tidak membuat asumsi tentang struktur objek dengan token atau cara memperbarui token, jadi Anda perlu memberikan beberapa argumen tambahan. Tetapi ide solusinya sama dan repositori juga berisi instruksi tentang cara menggunakannya.


Di sini saya dapat mengucapkan Terima kasih atas membaca artikel dan saya harap ini membantu Anda.

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


All Articles