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