Руководство по Convex. Часть 2

от автора

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

В этой серии статей я рассказываю о Convex — новом открытом и бесплатном решении BaaS (Backend as a Service — бэкенд как услуга), которое выглядит очень многообещающе и быстро набирает популярность среди разработчиков.

На сегодняшний день Convex предоставляет реактивную базу данных смешанного типа, механизм аутентификации/авторизации, файловое хранилище, планировщик задач и инструменты интеллектуального поиска.

Эта вторая часть серии, в которой мы поговорим об аутентификации и авторизации.

❯ Аутентификация

Аутентификация позволяет идентифицировать пользователей и ограничивать им доступ к данным при необходимости.

Convex Auth

Аутентификация может быть реализована прямо в Convex с помощью библиотеки Convex Auth, о которой мы поговорим в следующем разделе. Это легкий способ настройки регистрации/авторизации пользователей с помощью провайдеров аутентификации, одноразовых email/СМС или паролей.

Сторонние платформы аутентификации

В качестве альтернативы можно использовать интеграцию Convex со сторонними провайдерами аутентификации. Это немного сложнее, но эти платформы предоставляют продвинутый функционал поверх основ аутентификации:

  • Clerk — более новая платформа с лучшей поддержкой Next.js и React Native
  • Auth0 — более стабильная платформа с огромным количеством возможностей

❯ Convex Auth

Convex Auth — это библиотека для реализации аутентификации прямо в Convex. Она позволяет аутентифицировать пользователей без сервиса аутентификации или даже сервера.

Начало работы

Для создания нового проекта с Convex и Convex Auth необходимо выполнить команду:

npm create convex@latest

и выбрать React (Vite) и Convex Auth.

Про добавление Convex Auth в существующий проект мы поговорим позже.

Обзор

Convex Auth позволяет реализовать следующие методы аутентификации:

  1. Магические ссылки (magic links) и одноразовые пароли — отправка ссылки или кода по email.
  2. OAuth — авторизация через Github / Google / Apple и т.д.
  3. Пароли, включая сброс пароля и подтверждение email.

Библиотека не предоставляет готовых компонентов UI, но ничто не мешает скопировать код из примеров для быстрого прототипирования.

Обратите внимание, что Convex Auth — экспериментальная возможность. Это означает, что соответствующий код в будущем может претерпеть некоторые изменения.

Добавление в существующий проект

Устанавливаем необходимые пакеты:

npm i @convex-dev/auth @auth/core

Запускаем команду для инициализации:

npx @convex-dev/auth

Эта команда настраивает проект для аутентификации с помощью библиотеки. Такую настройку можно произвести вручную.

Добавляем таблицы аутентификации в схему:

// convex/schema.ts import { defineSchema } from 'convex/server' import { authTables } from '@convex-dev/auth/server'  const schema = defineSchema({   ...authTables,   // Другие таблицы })  export default schema

Добавление провайдера аутентификации отличается в зависимости от используемого фреймворка.

Vite

Заменяем ConvexProvider из convex/react на ConvexAuthProvider из @convex-dev/auth-react:

import { ConvexAuthProvider } from '@convex-dev/auth/react' import React from 'react' import ReactDOM from 'react-dom/client' import { ConvexReactClient } from 'convex/react' import App from './App.tsx' import './index.css'  const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string)  ReactDOM.createRoot(document.getElementById('root')!).render(   <React.StrictMode>     <ConvexAuthProvider client={convex}>       <App />     </ConvexAuthProvider>   </React.StrictMode>, )

Next.js

Оборачиваем приложение в ConvexAuthNextjsServerProvider из @convex-dev/auth/nextjs/server:

// app/layout.tsx import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server'  export default function RootLayout({   children, }: Readonly<{   children: React.ReactNode }>) {   return (     <ConvexAuthNextjsServerProvider>       <html lang='en'>         <body>{children}</body>       </html>     </ConvexAuthNextjsServerProvider>   ) }

В провайдере для клиента заменяем ConvexProvider из convex/react на ConvexAuthNextjsProvider из @convex-dev/auth/nextjs:

// app/ConvexClientProvider.tsx 'use client'  import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs' import { ConvexReactClient } from 'convex/react' import { ReactNode } from 'react'  const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)  export function ConvexClientProvider({ children }: { children: ReactNode }) {   return (     <ConvexAuthNextjsProvider client={convex}>       {children}     </ConvexAuthNextjsProvider>   ) }

Можно обернуть в клиентский провайдер все приложение:

// app/layout.tsx import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server' import { ConvexClientProvider } from './ConvexClientProvider'  export default function RootLayout({   children, }: Readonly<{   children: React.ReactNode }>) {   return (     <ConvexAuthNextjsServerProvider>       <html lang='en'>         <body>           <ConvexClientProvider>{children}</ConvexClientProvider>         </body>       </html>     </ConvexAuthNextjsServerProvider>   ) }

Наконец, добавляем файл middleware.ts, в котором используется функция convexAuthNextjsMiddleware:

import { convexAuthNextjsMiddleware } from '@convex-dev/auth/nextjs/server'  export default convexAuthNextjsMiddleware()  export const config = {   // Посредник запускается для всех роутов,   // кроме статических ресурсов   matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], }

React Native

Устанавливаем expo-secure-store:

npx expo install expo-secure-store

Заменяем ConvexProvider из convex/react на ConvexAuthProvider из @convex-dev/auth-react:

// app/_layout.tsx import { ConvexAuthProvider } from '@convex-dev/auth/react' import { ConvexReactClient } from 'convex/react' import { Stack } from 'expo-router' import * as SecureStore from 'expo-secure-store'  const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {   unsavedChangesWarning: false, })  const secureStorage = {   getItem: SecureStore.getItemAsync,   setItem: SecureStore.setItemAsync,   removeItem: SecureStore.deleteItemAsync, }  export default function RootLayout() {   return (     <ConvexAuthProvider client={convex} storage={secureStorage}>       <Stack>         <Stack.Screen name='index' />       </Stack>     </ConvexAuthProvider>   ) }

OAuth

Этот метод аутентификации включает 2 этапа:

  1. Пользователь нажимает на кнопку для авторизации с помощью третьей стороны (GitHub, Google, Apple и т.д.).
  2. Пользователь аутентифицируется на сайте третьей стороны, направляется обратно в приложение и авторизуется в нем.

Convex Auth обеспечивает безопасный обмен секретами между провайдером третьей стороны и нашим бэком.

Провайдеры

Convex Auth основан на Auth.js.

Auth.js позволяет использовать 80 разных провайдеров OAuth.

Рассмотрим настройку OAuth с помощью GitHub, потому что она является самой простой.

Настройка

Callback URL

После регистрации в качестве разработчика у провайдера, мы обычно создаем «приложение» (app) для хранения настроек OAuth.

Среди прочего, обычно требуется настроить callback URL, а также другие URL/домены.

Источником (доменом) callback URL для Convex Auth является HTTP Actions URL, который можно найти в панели управления. Он совпадает с CONVEX_URL, за исключением домена верхнего уровня, которым является .site, а не .cloud.

Например, если названием деплоя является fast-horse-123, то HTTP Actions URL будет выглядеть как https://fast-horse-123.convex.site, а callback URL для GitHub так:

https://fast-horse-123.convex.site/api/auth/callback/github

Переменные среды

Пример настройки переменных среды для GitHub:

npx convex env set AUTH_GITHUB_ID yourgithubclientid npx convex env set AUTH_GITHUB_SECRET yourgithubsecret

Подробнее про настройку переменных среды в Convex можно почитать здесь.

Настройка провайдера

Добавляем настройки провайдера в массив providers в файле convex/auth.ts:

import GitHub from '@auth/core/providers/github' import { convexAuth } from '@convex-dev/auth/server'  export const { auth, signIn, signOut, store } = convexAuth({   providers: [GitHub], })

Добавление кнопки авторизации

Поток (flow) авторизации запускается с помощью функции signIn, возвращаемой хуком useAuthActions.

Первым аргументом функции является ИД провайдера — его название в нижнем регистре (по умолчанию):

import { useAuthActions } from '@convex-dev/auth/react'  export function SignIn() {   const { signIn } = useAuthActions()    return (     <button onClick={() => void signIn('github')}>       Войти с помощью GitHub     </button>   ) }

Извлечение данных пользователя

По умолчанию в таблице users сохраняются только name, email и image пользователя.

Это можно изменить с помощью метода profile в настройках провайдера:

import GitHub from '@auth/core/providers/github' import { convexAuth } from '@convex-dev/auth/server'  export const { auth, signIn, signOut, store } = convexAuth({   providers: [     GitHub({       profile(githubProfile, tokens) {         return {           id: githubProfile.id,           name: githubProfile.name,           email: githubProfile.email,           image: githubProfile.picture,           githubId: githubProfile.id,         }       },     }),   ], })

В примере добавляется поле githubId. Не забудьте соответствующим образом модифицировать схему БД.

profile() должен обязательно возвращать поле id с уникальным ИД, который используется для идентификации аккаунта.

Пароли

Этот метод аутентификации основан на секретном пароле пользователя.

Солидная аутентификация на основе пароля предполагает способ сброса пароля (обычно через email или код).

Также может потребоваться подтверждение email (при регистрации или после) для обеспечения корректности email.

Настройка

Авторизация с помощью email (или имени пользователя) и пароля реализуется с помощью настройки провайдера Password.

Настраиваем провайдера:

import { Password } from '@convex-dev/auth/providers/Password' import { convexAuth } from '@convex-dev/auth/server'  export const { auth, signIn, signOut, store } = convexAuth({   providers: [Password], })

После этого можно запускать авторизацию/регистрацию при отправке формы с помощью функции signIn.

Регистрация и авторизация — это разные вещи. То, какой процесс выполняется, определяется с помощью поля flow:

import { useAuthActions } from '@convex-dev/auth/react'  export function SignIn() {   const { signIn } = useAuthActions()   // Регистрация или авторизация?   const [step, setStep] = useState<'signUp' | 'signIn'>('signIn')    return (     <form       onSubmit={(event) => {         event.preventDefault()         const formData = new FormData(event.currentTarget)         void signIn('password', formData)       }}     >       <input name='email' placeholder='Email' type='text' />       <input name='password' placeholder='Password' type='password' />       // Регистрация или авторизация? formaData.get("step")       <input name='flow' value={step} type='hidden' />       <button type='submit'>         {step === 'signIn' ? 'Войти' : 'Зарегистрироваться'}       </button>       <button         type='button'         onClick={() => {           setStep(step === 'signIn' ? 'signUp' : 'signIn')         }}       >         {step === 'signIn' ? 'Регистрация' : 'Вход'}       </button>     </form>   ) }

Сброс пароля с помощью email

Сброс пароля с помощью email включает в себя 2 этапа:

  1. Пользователь запрашивает ссылку для сброса пароля на email.
  2. Пользователь переходит по ссылке или вводит код на сайте и вводит новый пароль.

Провайдер Password поддерживает сброс пароля с помощью настройки reset, которая принимает провайдера email.

Создаем кастомного провайдера email:

import Resend from '@auth/core/providers/resend' import { Resend as ResendAPI } from 'resend' import { alphabet, generateRandomString } from 'oslo/crypto'  export const ResendOTPPasswordReset = Resend({   id: 'resend-otp',   apiKey: process.env.AUTH_RESEND_KEY,   async generateVerificationToken() {     return generateRandomString(8, alphabet('0-9'))   },   async sendVerificationRequest({ identifier: email, provider, token }) {     const resend = new ResendAPI(provider.apiKey)     const { error } = await resend.emails.send({       from: 'My App <onboarding@resend.dev>',       to: [email],       subject: 'Сброс пароля',       text: `Код для сброса пароля: ${token}`,     })      if (error) {       throw new Error('При отправке ссылки для сброса пароля произошла ошибка')     }   }, })

И используем его в convex/auth.ts:

import { Password } from '@convex-dev/auth/providers/Password' import { convexAuth } from '@convex-dev/auth/server' import { ResendOTPPasswordReset } from './ResendOTPPasswordReset'  export const { auth, signIn, signOut, store } = convexAuth({   providers: [Password({ reset: ResendOTPPasswordReset })], })

Поток сброса пароля идентифицируется с помощью значений "reset" и "reset-verification" настройки flow функции signIn:

import { useAuthActions } from '@convex-dev/auth/react'  export function PasswordReset() {   const { signIn } = useAuthActions()   const [step, setStep] = useState<'forgot' | { email: string }>('forgot')    return step === 'forgot' ? (     <form       onSubmit={(event) => {         event.preventDefault()         const formData = new FormData(event.currentTarget)         void signIn('password', formData).then(() =>           setStep({ email: formData.get('email') as string }),         )       }}     >       <input name='email' placeholder='Email' type='text' />       // Начинаем сброс       <input name='flow' value='reset' type='hidden' />       <button type='submit'>Отправить код</button>     </form>   ) : (     <form       onSubmit={(event) => {         event.preventDefault()         const formData = new FormData(event.currentTarget)         void signIn('password', formData)       }}     >       <input name='email' value={step.email} type='hidden' />       // Код       <input name='code' placeholder='Code' type='text' />       // Новый пароль       <input name='newPassword' placeholder='New password' type='password' />       // Завершаем сброс       <input name='flow' value='reset-verification' type='hidden' />       <button type='submit'>Продолжить</button>       <button type='button' onClick={() => setStep('signIn')}>         Отмена       </button>     </form>   ) }

Подтверждение email

Провайдер Password поддерживает подтверждение email с помощью настройки verify, принимающей провайдера email.

Создаем кастомного провайдера email:

import Resend from '@auth/core/providers/resend' import { Resend as ResendAPI } from 'resend' import { alphabet, generateRandomString } from 'oslo/crypto'  export const ResendOTP = Resend({   id: 'resend-otp',   apiKey: process.env.AUTH_RESEND_KEY,   async generateVerificationToken() {     return generateRandomString(8, alphabet('0-9'))   },   async sendVerificationRequest({ identifier: email, provider, token }) {     const resend = new ResendAPI(provider.apiKey)     const { error } = await resend.emails.send({       from: 'My App <onboarding@resend.dev>',       to: [email],       subject: 'Подтверждение email',       text: `Ваш код: ${token}`,     })      if (error) {       throw new Error(         'При отправке ссылки для подтверждения email возникла ошибка',       )     }   }, })

И используем его в convex/auth.ts:

import { Password } from '@convex-dev/auth/providers/Password' import { convexAuth } from '@convex-dev/auth/server' import { ResendOTP } from './ResendOTP'  export const { auth, signIn, signOut, store } = convexAuth({   providers: [Password({ verify: ResendOTP })], })

signIn() возвращает логическое значение, которое является индикатором успешной авторизации. В следующем примере мы это не проверяем, поскольку предполагаем полное размонтирование компонента после авторизации пользователя:

import { useAuthActions } from '@convex-dev/auth/react'  export function SignIn() {   const { signIn } = useAuthActions()   const [step, setStep] = useState<'signIn' | 'signUp' | { email: string }>(     'signIn',   )    return step === 'signIn' || step === 'signUp' ? (     <form       onSubmit={(event) => {         event.preventDefault()         const formData = new FormData(event.currentTarget)         void signIn('password', formData).then(() =>           setStep({ email: formData.get('email') as string }),         )       }}     >       <input name='email' placeholder='Email' type='text' />       <input name='password' placeholder='Password' type='password' />       <input name='flow' value={step} type='hidden' />       <button type='submit'>         {step === 'signIn' ? 'Войти' : 'Зарегистрироваться'}       </button>       <button         type='button'         onClick={() => {           setStep(step === 'signIn' ? 'signUp' : 'signIn')         }}       >         {step === 'signIn' ? 'Регистрация' : 'Вход'}       </button>     </form>   ) : (     <form       onSubmit={(event) => {         event.preventDefault()         const formData = new FormData(event.currentTarget)         void signIn('password', formData)       }}     >       <input name='code' placeholder='Code' type='text' />       <input name='email' value={step.email} type='hidden' />       <button type='submit'>Продолжить</button>       <button type='button' onClick={() => setStep('signIn')}>         Отмена       </button>     </form>   ) }

Валидация email и пароля

Для валидации данных, вводимых пользователем, можно воспользоваться следующими решениями:

  • Zod для валидации email и длины пароля, а также распределения логики между клиентом и сервером
  • haveibeenpwned для проверки утечки email
  • zxcvbn-ts для проверки «силы» пароля

Для этого достаточно передать настройку profile в провайдер Password.

Пример использования Zod:

import { Password } from '@convex-dev/auth/providers/Password' import { z } from 'zod'  const ParamsSchema = z.object({   email: z.string().email(),   password: z.string().min(16), })  export default Password({   profile(params) {     const { error, data } = ParamsSchema.safeParse(params)     if (error) {       throw new ConvexError(error.format())     }     return { email: data.email }   }, })

Авторизация

Авторизация

Авторизация зависит от выбранного метода аутентификации.

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

Для выхода из системы используется функция signOut:

import { useAuthActions } from '@convex-dev/auth/react'  export function SignOut() {   const { signOut } = useAuthActions()    return <button onClick={signOut}>Выход</button> }

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

convex/react предоставляет компоненты для проверки состояния авторизации пользователя:

import { Authenticated, Unauthenticated, AuthLoading } from 'convex/react' import { SignIn } from './SignIn' import { SignOut } from './SignOut'  export function App() {   return (     <>       <AuthLoading>{/* Индикатор загрузки */}</AuthLoading>       <Unauthenticated>         <SignIn />       </Unauthenticated>       <Authenticated>         <SignOut />         <Content />       </Authenticated>     </>   ) }  function Content() {   /* Защищенный контент */ }

Аутентификация операций HTTP

Для аутентификации вызовов операций HTTP требуется токен JWT, который возвращается хуком useAuthToken:

import { useAuthToken } from '@convex-dev/auth/react'  function SomeComponent() {   const token = useAuthToken()    const onClick = async () => {     const response = await fetch(       `${process.env.VITE_CONVEX_SITE_URL!}/someEndpoint`,       // Передаем токен в заголовке авторизации       { headers: { Authorization: `Bearer ${token}` } },     )     // ...   }   // ... }

Использование состояния аутентификации в функциях бэкенда

Доступ к информации о текущей авторизованном пользователе в функциях Convex можно получить с помощью вспомогательных функций из @convex-dev/auth/server.

Функции getAuthUserId и getAuthSessionId под капотом используют встроенную функцию ctx.auth.getUserIdentity и предоставляют типизированный апи.

Модель данных

Convex Auth создает таблицы users и authSession.

При регистрации пользователя создается документ в таблице users.

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

Один пользователь может иметь несколько активных сессий одновременно. Для веб-приложений одна сессия распределяется между всеми вкладками браузера по умолчанию.

Получение ИД авторизованного пользователя

Для получения ИД авторизованного пользователя вызывается getAuthUserId() с аргументом ctx:

import { getAuthUserId } from '@convex-dev/auth/server' import { query } from './_generated/server'  export const currentUser = query({   args: {},   handler: async (ctx) => {     const userId = await getAuthUserId(ctx)     if (userId === null) {       return null     }     return await ctx.db.get(userId)   }, })

Получение ИД текущей сессии

Для получения ИД текущей сессии вызывается getAuthSessionId() с аргументом ctx:

import { getAuthSessionId } from '@convex-dev/auth/server' import { query } from './_generated/server'  export const currentSession = query({   args: {},   handler: async (ctx) => {     const sessionId = await getAuthSessionId(ctx)     if (sessionId === null) {       return null     }     return await ctx.db.get(sessionId)   }, })

Серверная аутентификация в Next.js

Защита роутов

По умолчанию все роуты доступны без аутентификации. Защитить роуты от несанкционированного доступа можно в файле middleware.ts:

import {   convexAuthNextjsMiddleware,   createRouteMatcher,   isAuthenticatedNextjs,   nextjsMiddlewareRedirect, } from '@convex-dev/auth/nextjs/server'  const isSignInPage = createRouteMatcher(['/signin']) const isProtectedRoute = createRouteMatcher(['/product(.*)'])  export default convexAuthNextjsMiddleware((request) => {   if (isSignInPage(request) && isAuthenticatedNextjs()) {     return nextjsMiddlewareRedirect(request, '/product')   }   if (isProtectedRoute(request) && !isAuthenticatedNextjs()) {     return nextjsMiddlewareRedirect(request, '/signin')   } })  export const config = {   // Посредник запускается для всех роутов,   // кроме статических ресурсов   matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], }

Convex Auth предоставляет апи и вспомогательные функции для реализации посредника:

  • createRouteMatcher — функция, которая использует такой же синтаксис, что и config посредника. Мы вызываем ее со списком паттернов, и она возвращает функцию, которая возвращает переданный NextRequest при совпадении роута
  • функция isAuthenticatedNextjs возвращает индикатор авторизованности запроса. При использовании ConvexAuthNextjsServerProvider состояние аутентификации хранится как в http-only cookies, так и на клиенте, поэтому оно доступно также при запросах страницы
  • функция nextjsMiddlewareRedirect — сокращение для запуска перенаправлений

export function nextjsMiddlewareRedirect(   request: NextRequest,   pathname: string, ) {   const url = request.nextUrl.clone()   url.pathname = pathname   return NextResponse.redirect(url) }

Предварительная и обычная загрузка данных

Для предварительной и обычной загрузки данных на сервере Next.js из бэкенда Convex используются функции preloadQuery и fetchQuery, а также функция convexAuthNextjsToken:

import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server' import { preloadQuery } from 'convex/nextjs' import { api } from '@/convex/_generated/api' import { Tasks } from './Tasks'  export async function TasksWrapper() {   const preloadedTasks = await preloadQuery(     api.tasks.list,     { list: 'default' },     { token: convexAuthNextjsToken() },   )   return <Tasks preloadedTasks={preloadedTasks} /> }

Вызов аутентифицированных мутаций и операций

Мутации и операции могут вызываться из серверных операций (server actions) Next.js, а также из POST и PUT-обработчиков роута (route handlers):

import { api } from '@/convex/_generated/api' import { fetchMutation, fetchQuery } from 'convex/nextjs' import { revalidatePath } from 'next/cache'  export default async function PureServerPage() {   const tasks = await fetchQuery(api.tasks.list, { list: 'default' })    async function createTask(formData: FormData) {     'use server'      await fetchMutation(       api.tasks.create,       {         text: formData.get('text') as string,       },       { token: convexAuthNextjsToken() },     )     revalidatePath('/example')   }    return <form action={createTask}>...</form> }

❯ Clerk

Clerk — это платформа аутентификации, предоставляющая авторизацию через пароли, сторонних провайдеров, одноразовые email или СМС, мультифакторную аутентификацию и управление пользователями.

Начало работы

Предполагается, что у нас есть работающее приложение React с Convex.

Регистрируемся в Clerk

Создаем там приложение

Создаем шаблон JWT

В разделе JWT Templates кликаем по New template и выбираем Convex.

Копируем Issuer URL.

Нажимаем Apply Changes.

Обратите внимание: токен JWT должен называться convex.

Создаем настройку аутентификации

В директории convex создаем файл auth.config.ts с серверными настройками для валидации токенов доступа:

export default {   providers: [     {       // Issuer URL       domain: "https://your-issuer-url.clerk.accounts.dev/",       applicationID: "convex",     },   ] };

Деплоим изменения

npx convex dev

Устанавливаем Clerk

npm install @clerk/clerk-react

Получаем Publishable key в панели управления Clerk

Настраиваем ConvexProviderWithClerk

// src/main.tsx import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; import { ClerkProvider, useAuth } from "@clerk/clerk-react"; import { ConvexProviderWithClerk } from "convex/react-clerk"; import { ConvexReactClient } from "convex/react";  const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);  ReactDOM.createRoot(document.getElementById("root")!).render(   <React.StrictMode>     <ClerkProvider publishableKey="pk_test_...">       <ConvexProviderWithClerk client={convex} useAuth={useAuth}>         <App />       </ConvexProviderWithClerk>     </ClerkProvider>   </React.StrictMode>, );

Отображение UI в зависимости от состояния аутентификации

convex/react и @clerk/clerk-react предоставляют готовые компоненты для управления состоянием аутентификации пользователя:

import { SignInButton, UserButton } from "@clerk/clerk-react"; import { Authenticated, Unauthenticated, useQuery } from "convex/react"; import { api } from "../convex/_generated/api";  function App() {   return (     <main>       <Unauthenticated>         <SignInButton />       </Unauthenticated>       <Authenticated>         <UserButton />         <Content />       </Authenticated>     </main>   ); }  function Content() {   const messages = useQuery(api.messages.getForCurrentUser);    return <div>Защищенный контент: {messages?.length}</div>; }  export default App;

Использование состояние аутентификации в функциях Convex

Если пользователь аутентифицирован, его информация хранится в JWT и доступна через ctx.auth.getUserIdentity().

Если пользователь не аутентифицирован, ctx.auth.getUserIdentity() возвращает null.

import { query } from "./_generated/server";  export const getForCurrentUser = query({   args: {},   handler: async (ctx) => {     const identity = await ctx.auth.getUserIdentity();     if (identity === null) {       throw new Error("Unauthorized");     }      return await ctx.db       .query("messages")       .filter((q) => q.eq(q.field("author"), identity.email))       .collect();   }, });

Авторизация и выход из системы

Для создания потока авторизации используется компонент SignInButton. При ее нажатии открывается модальное окно авторизации Clerk:

import { SignInButton } from "@clerk/clerk-react";  function App() {   return (     <div className="App">       <SignInButton mode="modal" />     </div>   ); }

Для потока выхода из системы используется компонент SignOutButton или UserButton, который открывает модалку с соответствующей кнопкой.

Авторизованные и неавторизованные отображения

Для проверки состояния авторизованности пользователя следует использовать хук useConvexAuth вместо хука useAuth от Clerk. useConvexAuth() проверяет, что браузер получил токен аутентификации, необходимый для выполнения запросов к бэку Convex, а также, что бэк его провалидировал:

import { useConvexAuth } from "convex/react";  function App() {   const { isLoading, isAuthenticated } = useConvexAuth();    return (     <div className="App">       {isAuthenticated ? "Авторизован" : "Не авторизован"}     </div>   ); }

Также можно использовать вспомогательные компоненты Authenticated, Unauthenticated и AuthLoading вместо одноименных компонентов Clerk. Эти компоненты используют хук useConvex под капотом:

import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";  function App() {   return (     <div className="App">       <Authenticated>Авторизован</Authenticated>       <Unauthenticated>Не авторизован</Unauthenticated>       <AuthLoading>Загрузка...</AuthLoading>     </div>   ); }

Информация о пользователе в React

Доступ к данным пользователя можно получить с помощью хука useAuth от Clerk:

import { useUser } from "@clerk/clerk-react";  export default function Badge() {   const { user } = useUser();   return <span>Авторизован как {user.fullName}</span>; }

Под капотом

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

  1. Пользователь нажимает кнопку авторизации.
  2. Пользователь перенаправляется на страницу, где он авторизуется с помощью метода, настроенного в Clerk.
  3. После успешной авторизации Clerk направляет пользователя обратно в приложение на страницу авторизации или другую страницу, указанную в пропе afterSignIn.
  4. ClerkProvider теперь знает, что пользователь аутентифицирован.
  5. ConvexProviderWithClerk получает токен аутентификации от Clerk.
  6. ConvexReactClient передает этот токен в бэк Convex для валидации.
  7. Convex получает публичный ключ из Clerk для валидации сигнатуры токена.
  8. ConvexReactClient уведомляется об успешной аутентификации и ConvexProviderWithClerk теперь знает, что пользователь аутентифицирован в Convex. Хук useConvexAuth возвращает isAuthenticated: true и компонент Authenticated рендерит своих потомков.

ConvexProviderWithClerk заботится о получении токена при необходимости, чтобы пользователь оставался аутентифицированным в Convex.

❯ Auth0

Auth0 — это платформа аутентификации, предоставляющая авторизацию через пароли, сторонних провайдеров, одноразовые email или СМС, мультифакторную аутентификацию, SSO и управление пользователями.

В целом, настройка аутентификации с помощью Auth0 похожа на настройку аутентификации с помощью Clerk, за исключением некоторых особенностей.

❯ Аутентификация в функциях

Доступ к информации о текущей авторизованном пользователе в функциях Convex можно получить с помощью свойства auth объекта QueryCtx, MutationCtx или ActionCtx:

import { mutation } from "./_generated/server";  export const myMutation = mutation({   args: {     // ...   },   handler: async (ctx, args) => {     const identity = await ctx.auth.getUserIdentity();     if (identity === null) {       throw new Error("Unauthorized");     }     //...   }, });

UserIdentity

Объект UserIdentity, возвращаемый getUserIdentity(), гарантировано содержит поля tokenIdentifier, subject и issuer. Наличие других полей зависит от используемого провайдера аутентификации, а также от настроек токенов JWT и областей OpenID.

tokenIdentifier — это комбинация subject и issuer, что обеспечивает уникальность ИД, даже при использовании нескольких провайдеров.

import { mutation } from "./_generated/server";  export const myMutation = mutation({   args: {     // ...   },   handler: async (ctx, args) => {     const identity = await ctx.auth.getUserIdentity();     const { tokenIdentifier, name, email } = identity!;     //...   }, });

Операции HTTP

getUserIdentity() также позволяет получать доступ к данным пользователя в операциях HTTP для вызова конечных точек апи с правильным заголовком Authorization:

const jwtToken = "...";  fetch("https://<deployment name>.convex.site/myAction", {   headers: {     Authorization: `Bearer ${jwtToken}`,   }, });

❯ Хранение данных пользователей

Обратите внимание: при использовании Convex Auth данные пользователей сохраняются в БД Convex автоматически.

Хранить информацию о пользователях в БД Convex может потребоваться по следующим причинам:

  • нашим функциям нужна информация о других юзерах, а не только о текущем авторизованном
  • функциям нужен доступ к информации, дополнительной к полям, доступным в Open ID Connect JWT

Существует 2 способа хранения информации о пользователях в БД (только второй позволяет хранить информацию, не содержащуюся в JWT):

  1. Вызов мутации для сохранения информации из JWT, доступную в ctx.auth.
  2. Реализация веб-хука, вызываемого провайдером аутентификации при изменении пользовательской информации.

Вызов мутации на клиенте

Схема таблицы пользователей (опционально)

Можно определить таблицу "users", а также, опционально, индекс для эффективного поиска юзеров в БД.

В следующем примере мы используем tokenIdentifier из ctx.auth.getUserIdentity() для идентификации юзера, но с тем же успехом можно использовать subject или даже email.

// convex/schema.ts users: defineTable({   name: v.string(),   tokenIdentifier: v.string(), }).index("by_token", ["tokenIdentifier"]),

Мутация для сохранения текущего пользователя

// convex/users.ts import { mutation } from "./_generated/server";  export const store = mutation({   args: {},   handler: async (ctx) => {     const identity = await ctx.auth.getUserIdentity();     if (!identity) {       throw new Error("Called storeUser without authentication present");     }      // Проверяем наличие данных пользователя.     // Обратите внимание: вместо индекса можно использовать     // ctx.db.query("users")     //  .filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))     //  .unique();     const user = await ctx.db       .query("users")       .withIndex("by_token", (q) =>         q.eq("tokenIdentifier", identity.tokenIdentifier),       )       .unique();     if (user !== null) {       // Обновляем имя пользователя при необходимости       if (user.name !== identity.name) {         await ctx.db.patch(user._id, { name: identity.name });       }       return user._id;     }     // Создаем нового пользователя     return await ctx.db.insert("users", {       name: identity.name ?? "Анонимус",       tokenIdentifier: identity.tokenIdentifier,     });   }, });

Вызов мутации из React

Кастомный хук, вызывающий мутацию после авторизации:

// src/useStoreUserEffect.ts import { useUser } from "@clerk/clerk-react"; import { useConvexAuth } from "convex/react"; import { useEffect, useState } from "react"; import { useMutation } from "convex/react"; import { api } from "../convex/_generated/api"; import { Id } from "../convex/_generated/dataModel";  export function useStoreUserEffect() {   const { isLoading, isAuthenticated } = useConvexAuth();   const { user } = useUser();   // Установка этого состояния означает сохранение пользователя на сервере   const [userId, setUserId] = useState<Id<"users"> | null>(null);   const storeUser = useMutation(api.users.store);   // Вызываем мутацию `storeUser` для сохранения   // текущего пользователя в таблице `users` и возврата его ИД   useEffect(() => {     // Если пользователь не авторизован, ничего не делаем     if (!isAuthenticated) {       return;     }     // Сохраняем пользователя в БД.     // `storeUser()` получает данные пользователя через объект `auth`     // на сервере. Вручную ничего передавать не нужно     async function createUser() {       const id = await storeUser();       setUserId(id);     }     createUser();     return () => setUserId(null);     // Перезапускаем хук при авторизации пользователя через     // другого провайдера   }, [isAuthenticated, storeUser, user?.id]);   // Комбинируем локальное состояние с состоянием из контекста   return {     isLoading: isLoading || (isAuthenticated && userId === null),     isAuthenticated: isAuthenticated && userId !== null,   }; }

Используем этот хук в верхнеуровневом компоненте:

// src/App.tsx import { SignInButton, UserButton } from "@clerk/clerk-react"; import { useQuery } from "convex/react"; import { api } from "../convex/_generated/api"; import { useStoreUserEffect } from "./useStoreUserEffect.js";  function App() {   const { isLoading, isAuthenticated } = useStoreUserEffect();    return (     <main>       {isLoading ? (         <>Загрузка...</>       ) : !isAuthenticated ? (         <SignInButton />       ) : (         <>           <UserButton />           <Content />         </>       )}     </main>   ); }  function Content() {   const messages = useQuery(api.messages.getForCurrentUser);    return <div>Защищенный контент: {messages?.length}</div>; }  export default App;

В данном случае useStoreUserEffect() заменяет useConvexAuth().

Использование ИД пользователя

После сохранения данных пользователя, его ИД может использоваться как внешний ключ (foreign key) в других документах:

import { v } from "convex/values"; import { mutation } from "./_generated/server";  export const send = mutation({   args: { body: v.string() },   handler: async (ctx, args) => {     const identity = await ctx.auth.getUserIdentity();     if (!identity) {       throw new Error("Unauthorized");     }     const user = await ctx.db       .query("users")       .withIndex("by_token", (q) =>         q.eq("tokenIdentifier", identity.tokenIdentifier),       )       .unique();     if (!user) {       throw new Error("Unauthorized");     }     await ctx.db.insert("messages", { body: args.body, user: user._id });     // ...   }, });

Загрузка пользователя по ИД

import { query } from "./_generated/server";  export const list = query({   args: {},   handler: async (ctx) => {     const messages = await ctx.db.query("messages").collect();      return Promise.all(       messages.map(async (message) => {         // Для каждого сообщения извлекаем написавшего его         // пользователя и добавляем его имя в поле `author`         const user = await ctx.db.get(message.user);         return {           author: user?.name ?? "Анонимус",           ...message,         };       }),     );   }, });

Установка веб-хука

Мы будем использовать Clerk, но в Auth0 процесс похожий.

В данном случае Clerk будет вызывать бэк Convex через конечную точку HTTP при регистрации пользователя, обновлении или удалении его аккаунта.

Настройка конечной точки веб-хука

На панели управления Clerk переходим в Webhooks и нажимаем «+ Add Endpoint».

Устанавливаем Endpoint URL в значение https://<название-деплоя>.convex.site/clerk-users-webhook (обратите внимание, что домен заканчивается на .site, а не на .cloud). Название деплоя можно найти в файле .env.local в директории проекта или в панели управления Convex как часть Deployment URL.

В Message Filtering выбираем user для всех пользовательских событий.

Нажимаем Create.

После сохранения конечной точки копируем Signing Secret, который должен начинаться с whsec_. Устанавливаем его в качестве значения переменной окружения CLERK_WEBHOOK_SECRET в панели управления Convex.

Схема таблицы пользователей (опционально)

Можно определить таблицу "users", а также, опционально, индекс для эффективного поиска юзеров в БД:

users: defineTable({   name: v.string(),   // Это Clerk ID, хранящийся в поле `subject` JWT   externalId: v.string(), }).index("byExternalId", ["externalId"]),

Мутации для вставки и удаления пользователей

// convex/users.ts import { internalMutation, query, QueryCtx } from "./_generated/server"; import { UserJSON } from "@clerk/backend"; import { v, Validator } from "convex/values";  // Передает данные юзера клиенту для определения // успешности выполнения веб-хука export const current = query({   args: {},   handler: async (ctx) => {     return await getCurrentUser(ctx);   }, });  // Вызывается при регистрации юзера или обновлении его аккаунта export const upsertFromClerk = internalMutation({   args: { data: v.any() as Validator<UserJSON> }, // валидации нет, мы доверяем Clerk   async handler(ctx, { data }) {     const userAttributes = {       name: `${data.first_name} ${data.last_name}`,       externalId: data.id,     };      const user = await userByExternalId(ctx, data.id);     if (user === null) {       await ctx.db.insert("users", userAttributes);     } else {       await ctx.db.patch(user._id, userAttributes);     }   }, });  // Вызывается при удалении пользователя export const deleteFromClerk = internalMutation({   args: { clerkUserId: v.string() },   async handler(ctx, { clerkUserId }) {     const user = await userByExternalId(ctx, clerkUserId);      if (user !== null) {       await ctx.db.delete(user._id);     } else {       console.warn(         `Невозможно удалить пользователя, отсутствует его Clerk ID: ${clerkUserId}`,       );     }   }, });  // Возвращает данные текущего авторизованного пользователя // или выбрасывает исключение export async function getCurrentUserOrThrow(ctx: QueryCtx) {   const userRecord = await getCurrentUser(ctx);   if (!userRecord) {     throw new Error("Пользователь отсутствует");   }   return userRecord; }  // Возвращает данные текущего авторизованного пользователя или `null` export async function getCurrentUser(ctx: QueryCtx) {   const identity = await ctx.auth.getUserIdentity();   if (identity === null) {     return null;   }   return await userByExternalId(ctx, identity.subject); }  // Извлекает данные юзера по Clerk ID async function userByExternalId(ctx: QueryCtx, externalId: string) {   return await ctx.db     .query("users")     .withIndex("byExternalId", (q) => q.eq("externalId", externalId))     .unique(); }

Конечная точка веб-хука

// convex/http.ts import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server"; import { internal } from "./_generated/api"; import type { WebhookEvent } from "@clerk/backend"; import { Webhook } from "svix";  const http = httpRouter();  http.route({   path: "/clerk-users-webhook",   method: "POST",   handler: httpAction(async (ctx, request) => {     const event = await validateRequest(request);     if (!event) {       return new Response("Возникла ошибка", { status: 400 });     }     switch (event.type) {       case "user.created": // намеренное "проваливание"       case "user.updated":         await ctx.runMutation(internal.users.upsertFromClerk, {           data: event.data,         });         break;        case "user.deleted": {         const clerkUserId = event.data.id!;         await ctx.runMutation(internal.users.deleteFromClerk, { clerkUserId });         break;       }       default:         console.log("Игнорируемое событие веб-хука", event.type);     }      return new Response(null, { status: 200 });   }), });  async function validateRequest(req: Request): Promise<WebhookEvent | null> {   const payloadString = await req.text();   const svixHeaders = {     "svix-id": req.headers.get("svix-id")!,     "svix-timestamp": req.headers.get("svix-timestamp")!,     "svix-signature": req.headers.get("svix-signature")!,   };   const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);   try {     return wh.verify(payloadString, svixHeaders) as unknown as WebhookEvent;   } catch (error) {     console.error("Ошибка при подтверждении события веб-хука", error);     return null;   } }  export default http;

Использование данных пользователя

import { v } from "convex/values"; import { mutation } from "./_generated/server"; import { getCurrentUserOrThrow } from "./users";  export const send = mutation({   args: { body: v.string() },   handler: async (ctx, args) => {     const user = await getCurrentUserOrThrow(ctx);     await ctx.db.insert("messages", { body: args.body, userId: user._id });   }, });

Получение данных пользователя

export const list = query({   args: {},   handler: async (ctx) => {     const messages = await ctx.db.query("messages").collect();     return Promise.all(       messages.map(async (message) => {         const user = await ctx.db.get(message.user);         return {           author: user?.name ?? "Анонимус",           ...message,         };       }),     );   }, });

Ожидание сохранения текущего пользователя

Хук для определения состояние аутентификации текущего юзера с проверкой наличия его данных в БД:

// src/useCurrentUser.ts import { useConvexAuth, useQuery } from "convex/react"; import { api } from "../convex/_generated/api";  export function useCurrentUser() {   const { isLoading, isAuthenticated } = useConvexAuth();   const user = useQuery(api.users.current);   // Комбинируем состояние аутентификации с проверкой наличия пользователя   return {     isLoading: isLoading || (isAuthenticated && user === null),     isAuthenticated: isAuthenticated && user !== null,   }; }

Затем можно рендерить соответствующие компоненты:

import { useCurrentUser } from "./useCurrentUser";  export default function App() {   const { isLoading, isAuthenticated } = useCurrentUser();    return (     <main>       {isLoading ? (         <>Загрузка...</>       ) : isAuthenticated ? (         <Content />       ) : (         <LoginPage />       )}     </main>   ); }

На этом вторая часть руководства завершена. До встречи в следующей части.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


ссылка на оригинал статьи https://habr.com/ru/articles/851244/