Skip to content

Latest commit

 

History

History

react-auth0

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

React: пример использования Auth0 для разработки сервиса аутентификации/авторизации

Let's build web app auth service using Auth0.

Auth0 - это платформа, предоставляющая готовые решения для разработки сервисов любого уровня сложности. Auth0 поддерживается командой, стоящей за разработкой JWT (JSON Web Token/веб-токен в формате JSON). Это вселяет определенную уверенность в безопасности Auth0-сервисов.

Бесплатная версия Auth0 позволяет регистрировать до 7000 пользователей.

В этой статье я писал о том, что такое JWT, и как разработать собственный сервис с нуля.

Знакомство с Auth0 можно начать отсюда.

Исходный код Auth0 SDK, который мы будем использовать для разработки приложения, можно найти здесь.

В статье я расскажу только о самых основных возможностях, предоставляемых Auth0.

В примерах и на скриншотах ниже вы увидите реальные чувствительные данные/sensitive data. Это не означает, что вы сможете их использовать.

Подготовка и настройка проекта

Создаем директорию, переходим в нее и создаем клиента - шаблон React/TypeScript-приложения с помощью Create React App:

mkdir react-auth0
cd react-auth0 # cd !$

yarn create react-app client --template typescript
# or
npx create-react-app ...

Создаем директорию для сервера, переходим в нее и инициализируем Node.js-приложение:

mkdir server
cd server

yarn init -yp
# or
npm init -y

Создаем аккаунт Auth0:


Создаем tenant/арендатора:


Создаем одностраничное приложение/single page application на вкладке Applications/Applications:



Переходим в раздел Settings:


Создаем файл .env в директории client и записываем в него значения полей Domain и Client ID:

REACT_APP_AUTH0_DOMAIN = auth0-test-app.eu.auth0.com
REACT_APP_AUTH0_CLIENT_ID = Ykv47YaNC3naGvfljFt8LyhzVPRPZCJY

Прописываем URL клиента в полях Allowed Callback URLs, Allowed Logout URLs и Allowed Web Origins:


Сохраняем изменения.

Создаем API на вкладке Applications/API:



Переходим в раздел Settings:


Записываем значение поля Identifier и URL сервера в файл .env:

REACT_APP_AUTH0_AUDIENCE='https://auth0-test-app'
REACT_APP_SERVER_URI='http://localhost:4000/api'

Создаем файл .env в директории server следующего содержания:

AUTH0_DOMAIN='auth0-test-app.eu.auth0.com'
AUTH0_AUDIENCE='https://auth0-test-app'
CLIENT_URI='http://localhost:3000'

Клиент

Переходим в директорию client и устанавливаем дополнительные зависимости:

cd client

# зависимости для продакшна
yarn add @auth0/auth0-react react-router-dom react-loader-spinner
# зависимость для разработки
yarn add -D sass

Структура директории src:

- api
 - messages.ts
- components
 - AuthButton
   - LoginButton
     - LoginButton.tsx
   - LogoutButton
     - LogoutButton.tsx
   - AuthButton.tsx
 - Boundary
   - Error
     - error.scss
     - Error.tsx
   - Spinner
     - Spinner.tsx
   - Boundary.tsx
 - Navbar
   - Navbar.tsx
- pages
 - AboutPage
   - AboutPage.tsx
 - HomePage
   - HomePage.tsx
 - MessagePage
   - message.scss
   - MessagePage.tsx
 - ProfilePage
   - profile.scss
   - ProfilePage.tsx
- providers
 - AppProvider.tsx
 - Auth0ProviderWithNavigate.tsx
- router
 - AppRoutes.tsx
 - AppLinks.tsx
- styles
 - _mixins.scss
 - _variables.scss
- types
 - index.d.ts
- utils
 - createStore.tsx
- App.scss
- App.tsx
- index.tsx
...

Логика работы приложения:

  • в панели для навигации имеется кнопка для авторизации;
  • кнопка рендерится условно в зависимости от статуса авторизации пользователя;
  • если пользователь не авторизован, при нажатии кнопки он перенаправляется в Auth0 для выполнения входа в систему;
  • если пользователь авторизован, при нажатии кнопки выполняется выход из системы;
  • в приложении имеется 4 страницы: HomePage, AboutPage, ProfilePage и MessagePage;
  • первые две страницы находятся в открытом доступе;
  • последние две - требуют авторизации;
  • при переходе неавторизованного пользователя на страницу ProfilePage, он перенаправляется в Auth0;
  • после входа в систему пользователь возвращается на страницу ProfilePage, где видит информацию о своем профиле;
  • на странице MessagePage пользователь может отправить два запроса к серверу: на получение открытого сообщения и на получение защищенного сообщения;
  • если пользователь не авторизован, при отправке запроса на получение защищенного сообщения возвращается ошибка.

Дальше я буду рассказывать только о том, что касается непосредственно Auth0.

Интеграция приложения с Auth0

Для интеграции приложения с Auth0 используется провайдер Auth0Provider.

Для того, чтобы иметь возможность переправлять пользователя на кастомную страницу после входа в систему, дефолтный провайдер необходимо апгрейдить следующим образом (providers/Auth0ProviderWithNavigate):

// импортируем дефолтный провайдер
import { Auth0Provider } from '@auth0/auth0-react'
// хук для выполнения программной навигации
import { useNavigate } from 'react-router-dom'
import { Children } from 'types'

const domain = process.env.REACT_APP_AUTH0_DOMAIN as string
const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID as string
const audience = process.env.REACT_APP_AUTH0_AUDIENCE as string

const Auth0ProviderWithNavigate = ({ children }: Children) => {
 const navigate = useNavigate()

 // функция, вызываемая после авторизации
 const onRedirectCallback = (appState: { returnTo?: string }) => {
   // путь для перенаправления указывается в свойстве `returnTo`
   // по умолчанию пользователь возвращается на текущую страницу
   navigate(appState?.returnTo || window.location.pathname)
 }

 return (
   <Auth0Provider
     domain={domain}
     clientId={clientId}
     // данная настройка нужна для взаимодействия с сервером
     audience={audience}
     redirectUri={window.location.origin}
     onRedirectCallback={onRedirectCallback}
   >
     {children}
   </Auth0Provider>
 )
}

export default Auth0ProviderWithNavigate

С сигнатурой провайдера можно ознакомиться здесь.

Оборачиваем компоненты приложения в провайдер (index.tsx):

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Auth0ProviderWithNavigate from 'providers/Auth0ProviderWithNavigate'
import { AppProvider } from 'providers/AppProvider'
import App from './App'

ReactDOM.render(
 <React.StrictMode>
   {/* провайдер маршрутизации */}
   <BrowserRouter>
     {/* провайдер авторизации */}
     <Auth0ProviderWithNavigate>
       {/* провайдер состояния */}
       <AppProvider>
         <App />
       </AppProvider>
     </Auth0ProviderWithNavigate>
   </BrowserRouter>
 </React.StrictMode>,
 document.getElementById('root')
)

Вход и выход из системы

Для входа в систему используется метод loginWithRedirect, а для выхода - метод logout. Оба метода возвращаются хуком useAuth0. useAuth0 также возвращает логическое значение isAuthenticated (и много чего еще) - статус авторизации, который можно использовать для условного рендеринга кнопок.

Вот как реализована кнопка для аутентификации (components/AuthButton/AuthButton.tsx):

// импортируем хук
import { useAuth0 } from '@auth0/auth0-react'
import { LoginButton } from './LoginButton/LoginButton'
import { LogoutButton } from './LogoutButton/LogoutButton'

export const AuthButton = () => {
 // получаем статус авторизации
 const { isAuthenticated } = useAuth0()

 return isAuthenticated ? <LogoutButton /> : <LoginButton />
}

Кнопка для входа в систему (components/AuthButton/LoginButton/LoginButton.tsx):

// импортируем хук
import { useAuth0 } from '@auth0/auth0-react'

export const LoginButton = () => {
 // получаем метод для входа в систему
 const { loginWithRedirect } = useAuth0()

 return (
   <button className='auth login' onClick={loginWithRedirect}>
     Log In
   </button>
 )
}

Кнопка для выхода из системы (components/AuthButton/LogoutButton/LogoutButton.tsx):

// импортируем хук
import { useAuth0 } from '@auth0/auth0-react'

export const LogoutButton = () => {
 // получаем метод для выхода из системы
 const { logout } = useAuth0()

 return (
   <button
     className='auth logout'
     // после выхода из системы, пользователь перенаправляется на главную страницу
     onClick={() => logout({ returnTo: window.location.origin })}
   >
     Log Out
   </button>
 )
}

С сигнатурой хука можно ознакомиться здесь.

Состояние авторизации

Состояние авторизации пользователя сохраняется на протяжении времени жизни id_token/токена идентификации. Время жизни токена устанавливается на вкладке Settings приложения в поле ID Token Expiration раздела ID Token и по умолчанию составляет 36 000 секунд или 10 часов:


Токен записывается в куки, которые можно найти в разделе Storage/Cookies вкладки Application инструментов разработчика в браузере:


Это означает, что статус авторизации пользователя сохраняется при перезагрузке страницы, закрытии/открытии вкладки браузера и т.д.

При выходе из системы куки вместе с id_token удаляется.


Создание защищенной страницы

Для защиты страницы от доступа неавторизованных пользователей предназначена утилита withAuthenticationRequired. Хук useAuth0, кроме прочего, возвращает объект user с нормализованными данными пользователя.

Страница ProfilePage реализована следующим образом (pages/ProfilePage/ProfilePage.tsx):

import './profile.scss'
import { useAuth0, withAuthenticationRequired } from '@auth0/auth0-react'
import { Spinner } from 'components/index.components'

// оборачиваем код компонента в утилиту
export const ProfilePage = withAuthenticationRequired(
 () => {
   // получаем данные пользователя
   const { user } = useAuth0()

   return (
     <>
       <h1>Profile Page</h1>
       <div className='profile'>
         <img src={user?.picture} alt={user?.name} />
         <div>
           <h2>{user?.name}</h2>
           <p>{user?.email}</p>
         </div>
       </div>
     </>
   )
 },
 {
   // обе настройки являются опциональными
   returnTo: '/profile',
   onRedirecting: () => <Spinner />
 }
)

С сигнатурой утилиты можно ознакомиться здесь.

Проверка работоспособности клиента

Находясь в директории client, выполняем команду yarn start или npm start для запуска сервера для разработки:


Нажимаем на кнопку Log In. Попадаем на страницу регистрации/авторизации Auth0:


По умолчанию предоставляется возможность входа в систему с помощью аккаунта Google (Google OAuth 2.0). Позже мы добавим возможность авторизации с помощью аккаунта GitHub.

Входим в систему. Возвращаемся на главную страницу. Видим, что кнопка Log In сменилась кнопкой Log Out.


Выходим из системы. Пробуем перейти на страницу Profile. Снова попадаем на страницу Auth0. Входим в систему. Возвращаемся на страницу профиля:


Подключение GitHub

Переходим на вкладку Authentication/Social и нажимаем кнопку Create Connection:


Выбираем GitHub из предложенного списка:


Заходим в профиль GitHub. Переходим в раздел Settings/Developer settings/OAuth Apps и нажимаем на кнопку Register a new application:


Заполняем поля Application name, Homepage URL (https://ВАШ-ДОМЕН.auth0.com) и Authorization callback URL (https://ВАШ-ДОМЕН/login/callback):


Нажимаем на кнопку Generate a new client secret. Копируем значения полей Client ID и Client secret и вставляем их в соответствующие поля Auth0:



В разделе Attributes в дополнение к Basic Profile выбираем Email address, а в разделе Permissions - read:user.

Нажимаем на кнопку Create. Подключаем клиентское приложение и API.


Нажимаем на кнопку Try Connection для проверки соединения.

Нажимаем на кнопку Authorize ВАШЕ_ИМЯ.


Получаем сообщение о том, что соединение работает:


Теперь если мы нажмем Log In в приложении, то увидим, что у нас появилась возможность авторизоваться через GitHub:


Что касается Google, то Auth0 предоставляет тестовые ключи, которые должны быть заменены настоящими перед выпуском приложения в продакшн.

Займемся страницей MessagePage и сервером.

Интеграция клиента с сервером

API

Начнем с API (api/messages.ts):

// адрес сервера
const SERVER_URI = process.env.REACT_APP_SERVER_URI

// сервис для получения открытого сообщения
export async function getPublicMessage() {
 let data = { message: '' }
 try {
   const response = await fetch(`${SERVER_URI}/messages/public`)
   if (!response.ok) throw response
   data = await response.json()
 } catch (e) {
   throw e
 } finally {
   return data.message
 }
}

// сервис для получения защищенного сообщения
// функция принимает `access_token/токен доступа`
export async function getProtectedMessage(token: string) {
 let data = { message: '' }
 try {
   const response = await fetch(`${SERVER_URI}/messages/protected`, {
     headers: {
       // добавляем заголовок авторизации с токеном
       Authorization: `Bearer ${token}`
     }
   })
   if (!response.ok) throw response
   data = await response.json()
 } catch (e) {
   throw e
 } finally {
   return data.message
 }
}

Страница MessagePage (pages/MessagePage/MessagePage.tsx).

Импортируем хуки, компонент, провайдер, сервисы и стили:

import { useAuth0 } from '@auth0/auth0-react'
import { getProtectedMessage, getPublicMessage } from 'api/messages'
import { Boundary } from 'components/Boundary/Boundary'
import { useAppSetter } from 'providers/AppProvider'
import { useState } from 'react'
import './message.scss'

Получаем сеттеры, определяем состояние для сообщения и его типа:

export const MessagePage = () => {
 const { setLoading, setError } = useAppSetter()
 const [message, setMessage] = useState('')
 const [type, setType] = useState('')

 // TODO
}

Для генерации токена доступа (access_token) предназначен метод getAccessTokenSilently, возвращаемый хуком useAuth0:

const { getAccessTokenSilently } = useAuth0()

Определяем функцию для запроса открытого сообщения:

function onGetPublicMessage() {
   setLoading(true)
   getPublicMessage()
     .then(setMessage)
     .catch(setError)
     .finally(() => {
       setType('public')
       setLoading(false)
     })
 }

Определяем функцию для получения защищенного сообщения:

function onGetProtectedMessage() {
   setLoading(true)
   // генерируем токен и передаем его сервису `getProtectedMessage`
   getAccessTokenSilently()
     .then(getProtectedMessage)
     .then(setMessage)
     .catch(setError)
     .finally(() => {
       setType('protected')
       setLoading(false)
     })
 }

Наконец, возвращаем разметку:

return (
 <Boundary>
   <h1>Message Page</h1>
   <div className='message'>
     <button onClick={onGetPublicMessage}>Get Public Message</button>
     <button onClick={onGetProtectedMessage}>Get Protected Message</button>
     {message && <h2 className={type}>{message}</h2>}
   </div>
 </Boundary>
)

Сервер

Переходим в директорию server и устанавливаем зависимости:

# зависимости для продакшна
yarn add express helmet cors dotenv express-jwt jwks-rsa
# зависимости для разработки
yarn add -D nodemon
  • express: Node.js-фреймворк для разработки веб-серверов;
  • helmet: утилита для установки HTTP-заголовков, связанных с безопасностью. Об этих заголовках можно почитать здесь;
  • cors: утилита для установки HTTP-заголовков, связанных с CORS;
  • dotenv: утилита для работы с переменными среды окружения;
  • express-jwt: посредник/middleware для валидации JWT через модуль jsonwebtoken;
  • jwks-rsa: утилита для извлечения ключей подписания/signing keys из JWKS (JSON Web Key Set/набор веб-ключей в формате JSON);
  • nodemon: утилита для запуска сервера для разработки.

О том, что такое JWKS и для чего он используется, можно почитать здесь.

Пример интеграции jwks-rsa с express-jwt можно найти здесь.

Структура сервера:

- routes
 - api.routes.js
 - messages.routes.js
- utils
 - checkJwt.js
- .env
- index.js
- ...

Здесь нас интересуют 2 файла: messages.routes.js и checkJwt.js.

messages.routes.js:

import { Router } from 'express'
import { checkJwt } from '../utils/checkJwt.js'

const router = Router()

router.get('/public', (req, res) => {
 res.status(200).json({ message: 'Public message' })
})

router.get('/protected', checkJwt, (req, res) => {
 res.status(200).json({ message: 'Protected message' })
})

export default router

При запросе к api/messages/public возвращается сообщение Public message. При запросе к api/messages/protected выполняется проверка JWT. Данный маршрут (роут) является защищенным. Когда проверка прошла успешно, возвращается сообщение Protected message. В противном случае, утилита возвращает ошибку.

Рассмотрим этого посредника (utils/checkJwt.js).

Импортируем утилиты:

import jwt from 'express-jwt'
import jwksRsa from 'jwks-rsa'
import dotenv from 'dotenv'

Получаем доступ к переменным среды окружения, хранящимся в файле .env, и извлекаем их значения:

dotenv.config()

const domain = process.env.AUTH0_DOMAIN
const audience = process.env.AUTH0_AUDIENCE

audience - простыми словами, это аудитория токена, т.е. те, для кого предназначен токен.

Определяем утилиту:

export const checkJwt = jwt({
 secret: jwksRsa.expressJwtSecret({
   cache: true,
   // ограничение максимального количества запросов
   rateLimit: true,
   // 10 запросов в минуту
   jwksRequestsPerMinute: 10,
   // обратите внимание на сигнатуру пути
   jwksUri: `https://${domain}/.well-known/jwks.json`
 }),
 // аудитория
 audience,
 // тот, кто подписал токен
 issuer: `https://${domain}/`,
 // алгоритм, использованный для подписания токена
 algorithms: ['RS256']
})

Определяем тип кода сервера (модуль) и команды для запуска сервера в режиме для разработки и в производственном режиме в package.json:

"type": "module",
"scripts": {
 "start": "node index.js",
 "dev": "nodemon index.js"
}

Запускаем сервер для разработки с помощью команды yarn dev или npm run dev и возвращаемся в браузер.

Выходим из системы. Переходим на страницу MessagePage и пытаемся получить открытое сообщение:


Работает.

Теперь пробуем получить защищенное сообщение:


Получаем сообщение об ошибке, которое говорит о необходимости авторизации.

Авторизуемся и пробуем снова:


Получилось!

Кажется, что наш сервис аутентификации/авторизации работает, как ожидается.

Согласитесь, что Auth0 существенно облегчает выполнение нетривиальной задачи по разработке сервиса аутентификации/авторизации веб-приложения.

The End.