Ни электронной почты, ни имени пользователя, ни пароля. Войти только со своим кошельком

Контекст

В большинстве приложений мы аутентифицируем пользователей по их электронной почте, телефону или через провайдеров, таких как Google, GitHub и т. д. Это необходимо для большинства приложений, особенно если у вас есть нормативные требования или если вам нужно восстановить учетную запись или, возможно, вам нужно общаться со своими пользователями.

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

Поток

Процесс входа в систему довольно прост. Как показано на диаграмме ниже, он состоит из 2 шагов. Во-первых, создайте пользователя в базе данных с поступающим публичным адресом из внешнего интерфейса и назначьте случайный одноразовый номер (номер, используемый только один раз) или, если пользователь уже существует, извлеките его и верните одноразовый номер этого пользователя. Фронтенд будет использовать personal_sign, чтобы подписать это сообщение и отправить эту подпись обратно в бэкенд. После этого серверная часть извлечет пользователя и его одноразовый номер из базы данных и восстановит подпись, чтобы увидеть, подписана ли подпись с этим конкретным общедоступным адресом. Если все прошло успешно, обновите одноразовый номер из соображений безопасности и верните пользователю токен доступа или временные учетные данные.

Код

Я собираюсь использовать Deno в бэкенде, но в Node все почти так же. Для внешнего интерфейса я буду использовать React, он будет на 100% таким же, как и в других библиотеках/фреймворках во внешнем интерфейсе, просто отправляя запросы к API. Единственная вещь, зависящая от React, заключается в том, что у меня есть собственный хук useMetamask, но речь идет только о подключении к кошельку, вы можете реализовать такой же помощник в своей кодовой базе.

Внешний интерфейс

Я также буду использовать библиотеку ethers@6 во внешнем интерфейсе. Вы можете установить его с помощью npm.

useMetamask.tsx это настраиваемый контекст/крючок, позволяющий пользователям подключить свой кошелек к веб-сайту.

import { useContext, useState, useEffect, createContext, ReactNode, useMemo } from ‘react’
import { ethers } from ‘ethers’

interface MetamaskContextProps {
  isMetamaskInstalled: boolean,
  isMetamaskLoading: boolean,
  isMetamaskConnected: boolean,
  accounts: ethers.JsonRpcSigner[],
  provider: ethers.BrowserProvider,
  connectToMetamask: () => Promise<void>
}

const MetamaskContext = createContext<MetamaskContextProps>({} as MetamaskContextProps)

export function useMetamask() {
  return useContext(MetamaskContext)
}

export function MetamaskProvider({ children }: { children: ReactNode }) {
  const [isMetamaskLoading, setIsMetamaskLoading] = useState(false)
  const [isMetamaskInstalled, setIsMetamaskInstalled] = useState(false)
  const [isMetamaskConnected, setIsMetamaskConnected] = useState(false)
  const [accounts, setAccounts] = useState<MetamaskContextProps[‘accounts’]>([])
  const provider = useMemo<MetamaskContextProps[‘provider’]>(() => new ethers.BrowserProvider(window.ethereum, ‘any’), [])

  // set necessary states for the metamask wallet on mount
  useEffect(() => {
    !async function () {
      if (window.ethereum) {
        setIsMetamaskLoading(true)
        setIsMetamaskInstalled(true)
        const accounts = await provider.listAccounts()
        setAccounts(accounts)
        setIsMetamaskConnected(accounts.length > 0)
        setIsMetamaskLoading(false)
      }
    }()
  }, [])

  // send `eth_requestAccounts` which will show a popup to users to connect their wallet
  async function connectToMetamask() {
    if (window.ethereum) {
      try {
        const accounts = await window.ethereum.request({ method: ‘eth_requestAccounts’ })
        setAccounts(accounts)
        setIsMetamaskConnected(true)
      } catch (error) {
        console.error(error)
      }
    } else {
      console.error('Metamask not detected')
    }
  }

  const value = {
    isMetamaskInstalled,
    isMetamaskLoading,
    isMetamaskConnected,
    accounts,
    provider,
    connectToMetamask
  }

  return (
    <MetamaskContext.Provider value={value}>
      {children}
    </MetamaskContext.Provider>
  )
}

Затем, конечно, вы должны обернуть свой компонент MetamaskProvider, который экспортируется указанным выше хуком.

// in one of your parent component
return (
  <MetamaskProvider>
    <ConnectMetamask setLoading={setLoading} />
  </MetamaskProvider>
)

Теперь нам остался еще один шаг. Мы должны подключить наших пользователей, получить их публичный адрес и отправить его на серверную часть, чтобы получить одноразовый номер, а затем войти в систему. Давайте посмотрим на эту часть:

import Button, { Variant } from './Button'
import { useMetamask } from '@/hooks/useMetamask'
import { api } from '@/utils/api' // it's just an axios instance with application/json headers, nothing special

export default function ConnectMetamask() {
  const { isMetamaskConnected, connectToMetamask } = useMetamask()

  const connectWithMetamask = async () => {
    // if user is not already connected, force them to connect their wallet
    if (!isMetamaskConnected) return connectToMetamask()

    const selectedAddress = window.ethereum.selectedAddress

    // request to nonce endpoint to get a random nonce
    const { data: { nonce } } = await api.get(`/auth/metamask/nonce?address=${selectedAddress}`)
    // sign the nonce with the selected public address of the connected wallet
    const signature = await window.ethereum.request({
      method: 'personal_sign',
      params: [nonce, selectedAddress]
    })

    // send another request to login endpoint with the signature which is signed with the user's nonce
    await api.post(`/auth/metamask/login?address=${selectedAddress}`, { signature })

    // you can return an access token for the user, or maybe temporary credentials and then sign in, it's up to you
    // ...
  }

  return (
    <Button onClick={connectWithMetamask} variant={Variant.Tertiary} className="shadow-md flex items-center gap-4 rounded">
      Connect with MetaMask
    </Button>
  )
}

Бэкенд

Как я уже сказал, это серверная часть Deno, использующая фреймворк Oak, но почти все то же самое в Node. Начнем с настройки.

Прежде всего, в Deno у нас есть файл deno.json или import_map.json для импорта. Я использую deno.json со свойством imports.

deno.jsonc

{
  "tasks": {
    "dev": "deno run --allow-net --allow-read --allow-env --allow-ffi --watch main.ts",
  },
  "imports": {
    "std/": "https://deno.land/[email protected]/",
    "oak": "https://deno.land/x/[email protected]/mod.ts",
    "cors": "https://deno.land/x/[email protected]/mod.ts",
    "cuid": "npm:@paralleldrive/[email protected]",
    "@metamask/eth-sig-util": "npm:@metamask/[email protected]"
  }
}

main.ts и вот наша точка входа на сервер:

import "std/dotenv/load.ts";
import { Application } from "oak";
import { oakCors } from "cors";
import { router as authRouter } from "./auth/router.ts";

const app = new Application();

app
  .use(oakCors({ origin: "http://localhost:3000", credentials: true }))
  .use(authRouter.routes(), authRouter.allowedMethods());

await app.listen({ port: 8001 });

Теперь давайте перейдем к нашим маршрутам, что является наиболее важной частью.

import { Router } from "oak";
import { createId } from "cuid";
import { recoverPersonalSignature } from "@metamask/eth-sig-util";
import { getMetamaskUser, createMetamaskUser, updateMetamaskUser } from "./mock-queries.ts";

export const router = new Router();

router
  .get("/auth/metamask/nonce", async (ctx) => {
    // get the address from the query string (add your validation)
    const { address } = ctx.request.url.searchParams.get("address")

    // query the metamask user from the database with the public address
    const { data: user } = await getMetamaskUser(address);

    // if user does not exists, create a new one with a random nonce
    // and return the nonce, otherwise return the fetched user’s nonce
    if (!user) {
      const nonce = createId()
      await createMetamaskUser({
        provider: "metamask",
        nonce: createId(),
        address
      })

      return ctx.response.body = { nonce };
    } else {
      return ctx.response.body = { nonce: user.nonce };
    }
  })
  .post("/auth/metamask/login", async (ctx) => {
    // get the address from the query string and signature from the request body (add your validation)
    const { address } = ctx.request.url.searchParams.get("address")
    const { signature } = await ctx.request.body({ type: "json" }).value

    // query the metamask user again and throw 403 if not exists
    const { data: user } = await getMetamaskUser(address);
    if (!user) return ctx.throw(403);

    // use @metamask/eth-sig-util to recover personal signature by passing the data that was signed
    // and the signature that is sent by the frontend
    const recoveredAddress = recoverPersonalSignature({ data: user.raw_user_meta_data.nonce, signature });
    // this function will recover the address that was used to sign the nonce
    // so if recoveredAddress !== address means that the address sent is not the one who signs it, return 403
    if (recoveredAddress !== address) return ctx.throw(403);

    // renew the nonce of the user so that specific nonce is invalid from now on
    // otherwise if we keep use the same nonce it’s viewable on chain data
    // which will allow other users to compromise and act on behalf of other users
    const nonce = createId();
    await updateMetamaskUser({ nonce })

    // return the temporary credentials or return cookie
    return ctx.response.body = { ... };
  });

Также помните, что подписанное сообщение не обязательно должно быть одноразовым. Он может включать текст для информирования пользователей, если вы также восстанавливаете подпись из правильных данных. Если вы также подписываете текст, вы должны включить этот текст при восстановлении в бэкэнде.

Это делается только для целей аутентификации и не требует платы за газ.
Nonce: ${RANDOM_NONCE_HERE}

Вот и все! Теперь пользователи могут входить в систему только со своим кошельком MetaMask (конечно, вы можете реализовать то же самое и с другими кошельками).