
Привет, друзья!
В этой статье я покажу вам, как создать полноценный сервис для аутентификации и авторизации (далее — просто сервис) с помощью 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- 
@auth0/auth0-react — Auth0 SDKдляReact-приложений
- react-router-dom — библиотека для маршрутизации
- react-loader-spinner — индикатор загрузки
- 
sass — CSS-препроцессор
Структура директории 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_AUDIENCEaudience — простыми словами, это аудитория токена, т.е. те, для кого предназначен токен.
Определяем утилиту:
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 существенно облегчает выполнение нетривиальной задачи по разработке сервиса аутентификации/авторизации веб-приложения.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Надеюсь, вы нашли для себя что-то интересное и не зря потратили время.
Благодарю за внимание. Happy coding и счастливого Нового года!
 
           
 