ReactJS: Cài đặt chức năng Authentication

ReactJS: Cài đặt chức năng Authentication

Giới thiệu

Authentication là một trong những tính năng cơ bản và quan trọng trong tất cả các web app. Một app cần phải có tính năng đăng ký và đăng nhập.

Đây được xem là viên gạch đầu tiên để bạn build một app phục vụ hàng triệu người dùng. Cho nên hiểu rõ và cài đặt được chức năng login/logout là điều tiên quyết.

Một trong những cách phổ biến nhất để chứng thực (authen) người dùng là sử dụng username/password. Người dùng thường muốn nhập username/password một lần và sau đó họ có thể gọi API với token và lấy thông tin liên quan. Thậm chí khi họ đóng trình duyệt, chúng ta cũng phải lưu những thông tin người dùng để sau khi họ quay lại trang web, họ không cần đăng nhập lần nữa. Điều này tạo nên sự tiện lợi nhất định cho người dùng như đa số các web app đang làm như Facebook, Youtube, Google, …

Chứng thực người dùng là một bài toán khó ở phía server cho nên bạn có thể sử dụng các dịch vụ bên thứ ba như Auth0, Netlify Identity, Firebase Authentication Những dịch vụ đã xử lý và đảm bảo an toàn để quản lý user của bạn.

Thông thường một flow authentication phải gồm hai bước sau:

  1. Login thành công và server (hay bên thứ ba như Auth0) trả về token
  2. Sử dụng token để chèn vào header của request để lấy data

Tiến hành cài đặt

Thông thường chúng ta sẽ có hai component là: <AuthenticatedApp /><UnauthenticatedApp /> Chúng ta sẽ kiểu tra xem nếu đã có thông tin user thì render <AuthenticatedApp /> và ngược lại render <UnauthenticatedApp />

Bạn có thể tham khảo đoạn code sau:

import * as React from 'react'
import * as auth from 'auth-provider'
import {AuthenticatedApp} from './authenticated-app'
import {UnauthenticatedApp} from './unauthenticated-app'

function App() {
  const [user, setUser] = React.useState(null)

  const login = form => auth.login(form).then(u => setUser(u))
  const register = form => auth.register(form).then(u => setUser(u))
  const logout = () => {
    auth.logout()
    setUser(null)
  }

  return user ? (
    <AuthenticatedApp user={user} logout={logout} />
  ) : (
    <UnauthenticatedApp login={login} register={register} />
  )
}

export {App}

Giả sử chúng ta đã có hàm auth.loginauth.register. Sau khi gọi hai hàm này chúng ta lấy được thông tin người dùng và sử dụng conditional rendering để render component phù hợp.

<UnauthenticatedApp /> là màn hình bao gồm hai button để Login và Register user. Còn <AuthenticatedApp /> là màn hình hiển thị tất cả những quyển sách có sẵn trong cơ sở dữ liệu.

Màn hình của component <UnauthenticatedApp />
Màn hình của component <AuthenticatedApp />
// file auth-provider.js

// Giả sử bạn có cài đặt như sau cho auth-provider.  
// Trên thực tế bạn không nên lưu token vào localStorage
const localStorageKey = '__auth_provider_token__'

async function getToken() {
  // Nếu bạn sử dụng token provider như auth0, tại đây bạn sẽ gọi API
  // để lấy token, bạn không nên lưu token vào localStorage, đây chỉ là demo
  return window.localStorage.getItem(localStorageKey)
}

function handleUserResponse({user}) {
  window.localStorage.setItem(localStorageKey, user.token)
  return user
}

function login({username, password}) {
  return client('login', {username, password}).then(handleUserResponse)
}

function register({username, password}) {
  return client('register', {username, password}).then(handleUserResponse)
}

async function logout() {
  window.localStorage.removeItem(localStorageKey)
}

const authURL = process.env.REACT_APP_AUTH_URL

async function client(endpoint, data) {
  const config = {
    method: 'POST',
    body: JSON.stringify(data),
    headers: {'Content-Type': 'application/json'},
  }

  return window.fetch(`${authURL}/${endpoint}`, config).then(async response => {
    const data = await response.json()
    if (response.ok) {
      return data
    } else {
      return Promise.reject(data)
    }
  })
}

export {getToken, login, register, logout, localStorageKey}

Chèn token vào header của fetch

Sau khi đã login thành công và có token, bạn sẽ sử dụng token để gọi những API khác để lấy dữ liệu. Bạn có thể sửa lại apiClient ở bài trước như sau:

import * as auth from 'auth-provider'
const apiURL = process.env.REACT_APP_API_URL

function client(
  endpoint,
  {data, token, headers: customHeaders, ...customConfig} = {},
) {
  const config = {
    method: data ? 'POST' : 'GET',
    body: data ? JSON.stringify(data) : undefined,
    headers: {
      Authorization: token ? `Bearer ${token}` : undefined,
      'Content-Type': data ? 'application/json' : undefined,
      ...customHeaders,
    },
    ...customConfig,
  }

  return window.fetch(`${apiURL}/${endpoint}`, config).then(async response => {
    if (response.status === 401) {
      await auth.logout()
      // refresh the page for them
      window.location.assign(window.location)
      return Promise.reject({message: 'Please re-authenticate.'})
    }
    const data = await response.json()
    if (response.ok) {
      return data
    } else {
      return Promise.reject(data)
    }
  })
}

export {client}

Token sẽ được thêm vào Headers Authorization, sau đó mỗi khi gọi fetch thì server sẽ dựa vào token này để biết được user này đã login hay chưa và trả về data phù hợp.

Ở đây, nếu vì một lý do gì đó API response trả về error code 401 (user ko được authen) thì chúng ta sẽ gọi auth.logout và redirect app về trang login chẳng hạn.

Sử dụng Auth0

Nếu bạn sử dụng Auth0 để xử lí authen thì bạn có thể cài đặt package sau:

npm install @auth0/auth0-react

Có một cách khác là bạn có thể tạo một FetchProvider và dùng axios để xử lí tất cả quá trình lấy token, chèn token vào header, error 401. Bạn có thể tham khảo FetchProvider như sau:

import React, {
  createContext,
  useEffect,
  useState,
  useCallback
} from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import axios from 'axios';

const FetchContext = createContext();
const { Provider } = FetchContext;

const FetchProvider = ({ children }) => {
  const [accessToken, setAccessToken] = useState();
  const { getAccessTokenSilently } = useAuth0();

  const getAccessToken = useCallback(async () => {
    try {
      const token = await getAccessTokenSilently();
      setAccessToken(token);
    } catch (err) {
      console.log(err);
    }
  }, [getAccessTokenSilently]);

  useEffect(() => {
    getAccessToken();
  }, [getAccessToken]);

  const authAxios = axios.create({
    baseURL: process.env.REACT_APP_API_URL
  });

  authAxios.interceptors.request.use(
    config => {
      config.headers.Authorization = `Bearer ${accessToken}`;
      return config;
    },
    error => {
      return Promise.reject(error);
    }
  );

  authAxios.interceptors.response.use(
    response => {
      return response;
    },
    error => {
      const code =
        error && error.response ? error.response.status : 0;
      if (code === 401) {
        getAccessToken();
      }
      return Promise.reject(error);
    }
  );

  return (
    <Provider
      value={{
        authAxios
      }}
    >
      {children}
    </Provider>
  );
};

export { FetchContext, FetchProvider };

Chúng ta dùng getAccessTokenSilently để lấy token (ko còn lưu token trong localStorage) và axios interceptors để chèn token vào header.

Bảo vệ Route với Authen

Một trong những yêu cầu ở React web là chúng ta phải kiểm tra xem một page có được phép truy cập hay không dựa vào user info. Ví dụ page /dashboard chỉ cho user truy cập khi họ đã đăng nhập.

Dó đó chúng ta phải có cách kiểm tra và redirect user về home nếu họ truy cập vào một trang cần authen. Chúng ta có thể cài đặt như sau với React Router:

import React, { Suspense } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import {
  BrowserRouter as Router,
  Route,
  Switch,
  Redirect
} from 'react-router-dom';

const AuthenticatedRoute = ({ children, ...rest }) => {
  const { isAuthenticated, user } = useAuth0();
  return (
    <Route
      {...rest}
      render={() =>
        isAuthenticated ? (
          <AppShell>{children}</AppShell>
        ) : (
          <Redirect to="/" />
        )
      }
    ></Route>
  );
};

const AppRoutes = () => {
  const { isLoading } = useAuth0();
  if (isLoading) {
    return (
      <div className="h-screen flex justify-center">
        <LoadingLogo />
      </div>
    );
  }
  return (
    <>
      <Suspense fallback={<LoadingFallback />}>
        <Switch>
          <AuthenticatedRoute path="/dashboard">
            <Dashboard />
          </AuthenticatedRoute>
          <AdminRoute path="/inventory">
            <Inventory />
          </AdminRoute>
          <AuthenticatedRoute path="/account">
            <Account />
          </AuthenticatedRoute>
          <AuthenticatedRoute path="/settings">
            <Settings />
          </AuthenticatedRoute>
          <AuthenticatedRoute path="/users">
            <Users />
          </AuthenticatedRoute>
          <UnauthenticatedRoutes />
        </Switch>
      </Suspense>
    </>
  );

Bên cạnh đó chúng ta có thể thêm component AdminRoute để kiểm tra xem user đó có phải là admin hay ko? Vì một số trang ví dụ /inventory chỉ cho user admin access.

const AdminRoute = ({ children, ...rest }) => {
  const { user, isAuthenticated } = useAuth0();
  const roles =
    user['https://app.orbit/roles'];
  const isAdmin = roles[0] === 'admin' ? true : false;
  return (
    <Route
      {...rest}
      render={() =>
        isAuthenticated && isAdmin ? (
          <AppShell>{children}</AppShell>
        ) : (
          <Redirect to="/" />
        )
      }
    ></Route>
  );
};

Câu chuyện xử lí authentication ở những dự án tôi đã làm việc

Tôi đã có cơ hội làm qua nhiều dự án khác nhau như Penetrace (chuyên về marketing data của Thuỵ Sỹ), Liquid (là một sàn giao dịch Bitcoin của Nhật Bản), Immoscout24 (là một sàn giao dịch bất động sản của Thuỵ Sỹ).

Theo kinh nghiệm của tôi thì khi làm cho dự án Penetrace, họ sử dụng chủ yếu là cookie và ko có token. Sau khi log in thành công thì server sẽ set cookie vào trình duyệt với cái flag như HttpOnly, SameSite, Secure để chống lại các tấn công XSS, CSRF. Sau đó, khi client (web react) gọi API để lấy dữ liệu thì trình duyệt sẽ tự động chèn cookie vào request. Server sẽ dựa vào cookie để nhận biết user đó. Sau này khi tôi ko còn làm việc ở đây thì nghe đồn rằng họ đã chuyển sang dùng nhiều dịch vụ của AWS và chuyển sang dùng token.

Đối với dự án Liquid, mặc dù đây là một sàn giao dịch tiền điện tử lớn nhất tại Nhật Bản, và được yêu cầu là phải có tính bảo mật cao thì họ lại lưu token vào localStorage. Sau khi log in thành công thì dùng Javascript để set token vào localStorage. Cách này quá không an toàn vì hacker có thể chạy XSS để lấy được token và truy câp dữ liệu của người dùng. Sau đó được một anh FE trong team phát hiện và đề nghị fix thì họ đã chuyển sang dùng cookie để lưu token. Nhưng điều đáng nói ở đây là họ lại lưu cookie nhưng ko set flag httpOnly, và đây là một lỗ hỏng bởi vì cookie này có thể đọc và ghi bởi Javascript. Một lần nữa dễ bị tấn công XSS.

Còn với Immoscout24 hiện tại tôi thấy cách lưu trữ token và quản lí authentication khá ổn. Họ lưu cookie và Redux store lưu token. Sau khi login thành công thì token được set vào Redux store và sau đó chúng ta có thể chèn token vào axios request để lấy data như ví dụ ở trên. Bạn có thể nghĩ là lưu vào Redux store thì khi refresh page token sẽ mất. Đúng là nó sẽ mất, nhưng trong lúc refresh lại page, token lại được set vào Redux lại một lần nữa ở trên Node server (SSR) và đồng bộ với Redux store ở client (CSR). Do đó token ko bị mất. Với cách này chúng ta đảm bảo token ko lưu trong localStorage hay cookie và được lưu trên Node server. Do đó sẽ an toàn hơn hai cách trên.

Xem thêm những thủ thuật xịn cho Javascript tại Youtube của tôi.

Tham khảo: 

Bookshelf của Kent C.Dodds

Lưu token vào cookie của Ryan Chenkie

Khoá học React Security

Auth0 React SDK

Leave a Comment