Пошаговое руководство по аутентификации пользователей в вашем DApp с помощью «Войти с помощью Ethereum»
6 января 2023 г.В идеальном сценарии у децентрализованного приложения не должно быть централизованного внутреннего сервера, все должно быть в сети.
Тогда почему Backend?
- При использовании полностью ончейн-подхода нам нужно (по крайней мере сейчас) идти на компромисс с пользовательским интерфейсом.
- Не все должно быть в цепочке, это просто не имеет смысла. Разве не было бы здорово, если бы мы могли сделать наши приложения достаточно децентрализованными? n Существует отличная статья автора Варун Шринивасан о достаточной децентрализации, которую должен прочитать каждый
- Лучшая масштабируемость. Серверная часть может облегчить масштабирование децентрализованного приложения, выполняя задачи, которые в противном случае могли бы замедлить работу блокчейна или потреблять большое количество газа.
- Вычисления вне сети. Некоторые задачи, такие как обработка изображений или видео, могут быть ресурсоемкими, и их выполнение в блокчейне может оказаться нецелесообразным. Серверная часть может выполнять эти задачи и взаимодействовать с децентрализованным приложением по мере необходимости.
По этой или любой другой причине вам может понадобиться подключить серверную часть к вашему децентрализованному приложению, но как? давайте начнем
Итак, что мы строим в Deep? 👀
Децентрализованное приложение, которое подключается к вашему бэкенду, поддерживает сеанс и подключается к базе данных.
Посмотрите демонстрацию здесь.
Что такое SIWE (вход через Ethereum)?
Вход в систему с помощью Ethereum описывает, как учетные записи Ethereum проходят аутентификацию с помощью сторонних сервисов путем подписания стандартного формата сообщения, параметризованного областью действия, сведениями о сеансе и механизмами безопасности (например, одноразовым номером). Эта спецификация направлена на то, чтобы предоставить самостоятельную альтернативу централизованным поставщикам удостоверений, улучшить взаимодействие между автономными службами для аутентификации на основе Ethereum и предоставить поставщикам кошельков согласованный машиночитаемый формат сообщений для улучшения взаимодействия с пользователем и управления согласием.< /p>
Более подробную информацию можно найти здесь.
Вот несколько шагов, которым мы будем следовать
- Подключить кошелек
- Подпишите сообщение SIWE с помощью одноразового номера, сгенерированного серверной частью
- Проверьте отправленное сообщение SIWE и подпись с помощью POST-запроса.
- Добавить в сеанс проверенные поля SIWE (через JWT, файлы cookie и т. д.)
- Хранить информацию о пользователе (например, имя) в MongoDB
- Обновить информацию о пользователе.
- Поддержание сеансов ч/б обновления страницы
Инструменты, которые мы будем использовать:
- NextUI — Библиотека компонентов, конечно же, я ненавижу CSS и не собираюсь ничего писать это
- Wagmi — React Hooks для Ethereum
- Железная сессия
Клонируйте код из репозитория 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
И это обертка!
:::информация Также опубликовано здесь.
:::
Оригинал