Ни электронной почты, ни имени пользователя, ни пароля. Войти только со своим кошельком
Контекст
В большинстве приложений мы аутентифицируем пользователей по их электронной почте, телефону или через провайдеров, таких как 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 (конечно, вы можете реализовать то же самое и с другими кошельками).