Привет, друзья!


В этой статье я покажу вам, как создать полноценный сервис для аутентификации и авторизации (далее — просто сервис) с помощью 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, а в разделе Permissionsread: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 существенно облегчает выполнение нетривиальной задачи по разработке сервиса аутентификации/авторизации веб-приложения.


Пожалуй, это все, чем я хотел поделиться с вами в данной статье.


Надеюсь, вы нашли для себя что-то интересное и не зря потратили время.


Благодарю за внимание. Happy coding и счастливого Нового года!




Комментарии (0)