Пошаговое руководство по аутентификации пользователей в вашем DApp с помощью «Войти с помощью Ethereum»

Пошаговое руководство по аутентификации пользователей в вашем DApp с помощью «Войти с помощью Ethereum»

6 января 2023 г.

В идеальном сценарии у децентрализованного приложения не должно быть централизованного внутреннего сервера, все должно быть в сети.

Тогда почему Backend?

  1. При использовании полностью ончейн-подхода нам нужно (по крайней мере сейчас) идти на компромисс с пользовательским интерфейсом.
  2. Не все должно быть в цепочке, это просто не имеет смысла. Разве не было бы здорово, если бы мы могли сделать наши приложения достаточно децентрализованными? n Существует отличная статья автора Варун Шринивасан о достаточной децентрализации, которую должен прочитать каждый
  3. Лучшая масштабируемость. Серверная часть может облегчить масштабирование децентрализованного приложения, выполняя задачи, которые в противном случае могли бы замедлить работу блокчейна или потреблять большое количество газа.
  4. Вычисления вне сети. Некоторые задачи, такие как обработка изображений или видео, могут быть ресурсоемкими, и их выполнение в блокчейне может оказаться нецелесообразным. Серверная часть может выполнять эти задачи и взаимодействовать с децентрализованным приложением по мере необходимости.

По этой или любой другой причине вам может понадобиться подключить серверную часть к вашему децентрализованному приложению, но как? давайте начнем

Итак, что мы строим в Deep? 👀

Децентрализованное приложение, которое подключается к вашему бэкенду, поддерживает сеанс и подключается к базе данных.

Посмотрите демонстрацию здесь.

Что такое SIWE (вход через Ethereum)?

Вход в систему с помощью Ethereum описывает, как учетные записи Ethereum проходят аутентификацию с помощью сторонних сервисов путем подписания стандартного формата сообщения, параметризованного областью действия, сведениями о сеансе и механизмами безопасности (например, одноразовым номером). Эта спецификация направлена ​​на то, чтобы предоставить самостоятельную альтернативу централизованным поставщикам удостоверений, улучшить взаимодействие между автономными службами для аутентификации на основе Ethereum и предоставить поставщикам кошельков согласованный машиночитаемый формат сообщений для улучшения взаимодействия с пользователем и управления согласием.< /p>

Более подробную информацию можно найти здесь.

Вот несколько шагов, которым мы будем следовать

  1. Подключить кошелек
  2. Подпишите сообщение SIWE с помощью одноразового номера, сгенерированного серверной частью
  3. Проверьте отправленное сообщение SIWE и подпись с помощью POST-запроса.
  4. Добавить в сеанс проверенные поля SIWE (через JWT, файлы cookie и т. д.)
  5. Хранить информацию о пользователе (например, имя) в MongoDB
  6. Обновить информацию о пользователе.
  7. Поддержание сеансов ч/б обновления страницы

Инструменты, которые мы будем использовать:

  1. NextUI — Библиотека компонентов, конечно же, я ненавижу CSS и не собираюсь ничего писать это
  2. Wagmi — React Hooks для Ethereum
  3. Железная сессия

Клонируйте код из репозитория GitHub, чтобы выполнить его.

Шаг 1. Давайте сначала создадим несколько маршрутов API

в разделе pages/api создайте файл nonce.ts со следующим содержимым

страницы/api/nonce.ts

import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
import { generateNonce } from 'siwe'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      req.session.nonce = generateNonce()
      await req.session.save()
      res.setHeader('Content-Type', 'text/plain')
      res.send(req.session.nonce)
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, sessionOptions)

Затем добавьте маршрут API для проверки сообщения SIWE и создания сеанса пользователя.

страницы/api/verify.ts

import { handleLoginOrSignup } from 'core/services/user.service'
import { withIronSessionApiRoute } from 'iron-session/next'
import dbConnect from 'lib/dbConnect'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
import { SiweMessage } from 'siwe'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
    const { method } = req
    switch (method) {
        case 'POST':
            try {
                await dbConnect();
                const { message, signature } = req.body
                const siweMessage = new SiweMessage(message)
                const fields = await siweMessage.validate(signature)

                if (fields.nonce !== req.session.nonce) {
                    return res.status(422).json({ message: 'Invalid nonce.' })
                }

                req.session.siwe = fields;
        // maintaining users details in MongoDB from below line
            // we will get backto this later ignore for now
                const user = await handleLoginOrSignup(fields.address);
                req.session.user = user;
                await req.session.save()
                res.json({ ok: true })
            } catch (_error) {
                console.log('error -> verify', _error)
                res.json({ ok: false, error: _error })
            }
            break
        default:
            res.setHeader('Allow', ['POST'])
            res.status(405).end(`Method ${method} Not Allowed`)
    }
}

export default withIronSessionApiRoute(handler, sessionOptions)
const { message, signature } = req.body
const siweMessage = new SiweMessage(message)
const fields = await siweMessage.validate(signature)

Мы будем передавать сообщение и подпись, сделанные кошельком пользователя из внешнего интерфейса, на стороне API мы проверяем их, а одноразовый номер предотвратит повторные атаки.

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

// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import type { IronSessionOptions } from "iron-session";
import { IUser } from "models/User";

export const sessionOptions: IronSessionOptions = {
  password: 'passowrd_cookie',
  cookieName: "iron-session/examples/next.js",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
  },
};

// This is where we specify the typings of req.session.*
declare module "iron-session" {
  interface IronSessionData {
    siwe: any;
    nonce: any;
    user: IUser | undefined; // users details from DB
  }
}

Теперь давайте создадим API для проверки сеансов пользователей и возврата сведений о пользователях, если пользователь уже вошел в систему pages/api/me.ts. Примечание. findUserByAddress — это вспомогательный метод, который получает сведения о пользователе из MongoDB, вы можете просмотреть код на GitHub, чтобы изучить этот вспомогательный метод

import { findUserByAddress } from 'core/services/user.service'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      const address = req.session.siwe?.address
      if (address) {
        const user = await findUserByAddress(address)
        res.json({ address: req.session.siwe?.address, user })
        return
      }
      res.status(StatusCodes.UNAUTHORIZED).json({
        message: ReasonPhrases.UNAUTHORIZED,
      })
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, sessionOptions)

И, наконец, маршрут logout, где мы уничтожаем сеанс

import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'POST':
      await req.session.destroy()
      res.send({ ok: true })
      break
    default:
      res.setHeader('Allow', ['POST'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, sessionOptions)

Теперь давайте создадим файл состояния для хранения всех пользовательских данных и связанных с ними методов, которые мы собираемся использовать для этого контекстного API

import { IUser } from 'models/User'
import React, { createContext, useContext, useState } from 'react'
import { SiweMessage } from 'siwe'
import { Connector, useAccount, useConnect } from 'wagmi'
import axios from 'axios'
import { useRouter } from 'next/router'
import { toast } from 'react-toastify'

export interface IUserState {
  user: IUser | undefined
  loadingUser: boolean
  setUser: React.Dispatch<React.SetStateAction<undefined>>
  handleSignOut: () => void
  handleSignIn: (connector: Connector) => Promise<void>
}

const UserContext = createContext<IUserState>({
  user: undefined,
  setUser: () => {},
  loadingUser: false,
  handleSignOut: () => {},
  handleSignIn: async () => {},
})

export function UserState({ children }: { children: JSX.Element }) {
  const router = useRouter()
  const [user, setUser] = useState(undefined)
  const [, connect] = useConnect()
  const [loadingUser, setLoadingUser] = useState(false)
  const [, disconnect] = useAccount({
    fetchEns: true,
  })

  const handleSignOut = async () => {
    disconnect()
    await axios.post('/api/logout')
    setUser(undefined)
    router.replace('/')
  }

  const handleSignIn = async (connector: Connector) => {
    try {
      const res = await connect(connector) // connect from useConnect
      if (!res.data) throw res.error ?? new Error('Something went wrong')

      setLoadingUser(true)
      const nonceRes = await axios('/api/nonce')
      const message = new SiweMessage({
        domain: window.location.host,
        address: res.data.account,
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId: res.data.chain?.id,
        nonce: nonceRes.data,
      })

      const signer = await connector.getSigner()
      const signature = await signer.signMessage(message.prepareMessage())
      // console.log('message', message, { signature })
      await axios.post('/api/verify', {
        message,
        signature,
      })
      const me = await axios('/api/me')
      setUser(me.data.user)
      // It worked! User is signed in with Ethereum
    } catch (error) {
      // Do something with the error
      toast.error('Something went wrong!')
      handleSignOut()
      console.log('error', error)
    } finally {
      setLoadingUser(false)
    }
  }

  return (
    <UserContext.Provider
      value={{ user, setUser, handleSignOut, handleSignIn, loadingUser }}
    >
      {children}
    </UserContext.Provider>
  )
}

export function useUserContext() {
  return useContext(UserContext)
}

n Разбивка метода handleSignIn

const res = await connect(connector) // connect from useConnect
if (!res.data) throw res.error ?? new Error('Something went wrong')

мы подключены к кошельку здесь

После подключения пользователя мы генерируем случайный одноразовый номер с помощью созданного ранее API

const nonceRes = await axios('/api/nonce')
      const message = new SiweMessage({
        domain: window.location.host,
        address: res.data.account, // users waller address
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId: res.data.chain?.id,
        nonce: nonceRes.data,
      })

const signer = await connector.getSigner()
const signature = await signer.signMessage(message.prepareMessage())

затем мы просим пользователя подписать сообщение, используя конструктор SiweMessage, предоставленный SIWE, и создаем подпись

await axios.post('/api/verify', {
        message,
        signature,
      })
const me = await axios('/api/me')
setUser(me.data.user)

Проверка пользователей и вход в данные пользователя с помощью API me, который мы создали ранее, а затем установка данных пользователя в состояние

Теперь давайте создадим API для обновления сведений о пользователе в базе данных,

import withAuth from 'core/middleware/withAuth'
import { findUserById, updateUser } from 'core/services/user.service'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { isValidObjectId } from 'mongoose'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
    const { method, query } = req
    const queryId = query.id;

    if (!queryId || !isValidObjectId(queryId)) {
        res.status(StatusCodes.BAD_REQUEST).json({
            message: "Valid id is required"
        })
        return;
    }
    switch (method) {
        case 'PUT':
            return await handlePatchUser();
        default:
            res.setHeader('Allow', ['PUT'])
            res.status(405).end(`Method ${method} Not Allowed`)
    };

    async function handlePatchUser() {
        if (req.session.user?._id !== queryId) {
            res.status(StatusCodes.FORBIDDEN).json({
                message: ReasonPhrases.FORBIDDEN
            });
            return;
        }
        const user = await updateUser(queryId, req.body);
        if (!user) {
            res.status(StatusCodes.BAD_REQUEST).json({
                message: "User not found with requested id"
            })
        }
        res.json({
            user
        })
    }
}

export default withIronSessionApiRoute(withAuth(handler), sessionOptions)

Обратите внимание, что мы используем withAuth здесь это промежуточное ПО, которое мы создали, поэтому только авторизованные пользователи могут получить доступ к нашим маршрутам API Ссылка на код

Теперь давайте создадим профиль в pages/profile.ts

.
import type { NextPage } from 'next'
import Head from 'next/head'
import { BaseLayout } from 'components/ui/Layout/BaseLayout'
import { ComponentWithLayout } from '../_app'
import { useFormik } from 'formik'
import axios from 'axios'
import { useUserContext } from 'core/state/user.state'
import { useState } from 'react'
import { Button, Loading } from '@nextui-org/react'
import { toast } from 'react-toastify'

const Profile: NextPage = () => {
  const { user } = useUserContext()
  const [loading, setLoading] = useState(false)
  const formik = useFormik({
    enableReinitialize: true,
    initialValues: {
      ...user,
    },
    onSubmit: async (values) => {
      try {
        setLoading(true)
        await axios.put(`/api/users/${user?._id}`, values)
        toast.success('Data saved successfully')
      } catch (error: any) {
        toast.error(error.message)
      } finally {
        setLoading(false)
      }
    },
  })

  return (
    <div className="flex flex-col items-center justify-center py-2">
      <Head>
        <title>Profile</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <section className="bg-blueGray-100 rounded-b-10xl">
        <div className="container mx-auto px-4">
          <div className="-mx-4 flex flex-wrap">
            <div className="w-full px-4">
              <div className="mx-auto max-w-xl rounded-xl bg-white py-14 px-8 md:px-20 md:pt-16 md:pb-20">
                <h3 className="font-heading mb-12 text-4xl font-medium">
                  Profile Details
                </h3>
                <input
                  className="placeholder-darkBlueGray-400 mb-5 w-full rounded-xl border px-12 py-5 text-xl focus:bottom-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
                  type="text"
                  placeholder="Your Name"
                  name="name"
                  onChange={formik.handleChange}
                  value={formik.values.name}
                />

                <div className="text-right">
                  <Button
                    clickable={!loading}
                    color="primary"
                    className="inline-block w-full  text-center text-xl font-medium tracking-tighter md:w-auto"
                    onClick={formik.submitForm}
                    size="lg"
                    icon={
                      loading && (
                        <Loading type="spinner" color="white" size="md" />
                      )
                    }
                  >
                    Save
                  </Button>
                </div>
              </div>
            </div>
          </div>
        </div>
      </section>
    </div>
  )
}

export default Profile
;(Profile as ComponentWithLayout).Layout = BaseLayout

И это обертка!

:::информация Также опубликовано здесь.

:::


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE