Перейти к основному содержанию

Использование Ethereum для аутентификации web2

web2
аутентификация
eas
Beginner
Ori Pomerantz
30 апреля 2025 г.
18 минута прочтения

Введение

SAML (opens in a new tab) — это стандарт, используемый в web2, который позволяет поставщику удостоверений (IdP) (opens in a new tab) предоставлять информацию о пользователе поставщикам услуг (SP) (opens in a new tab).

В этом руководстве вы узнаете, как интегрировать подписи Ethereum с SAML, чтобы позволить пользователям использовать свои кошельки Ethereum для аутентификации в сервисах web2, которые пока не поддерживают Ethereum нативно.

Обратите внимание, что это руководство предназначено для двух разных аудиторий:

  • Пользователи Ethereum, которые разбираются в Ethereum и хотят изучить SAML
  • Пользователи Web2, которые разбираются в SAML и аутентификации web2 и хотят изучить Ethereum

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

SAML для пользователей Ethereum

SAML — это централизованный протокол. Поставщик услуг (SP) принимает утверждения (например, "это мой пользователь John, у него должны быть разрешения на выполнение A, B и C") от поставщика удостоверений (IdP), только если у него есть предварительно установленные доверительные отношения либо с ним, либо с центром сертификации (opens in a new tab), который подписал сертификат этого IdP.

Например, SP может быть туристическим агентством, предоставляющим туристические услуги компаниям, а IdP — внутренним веб-сайтом компании. Когда сотрудникам необходимо забронировать деловую поездку, туристическое агентство отправляет их на аутентификацию в компанию, прежде чем позволить им фактически забронировать поездку.

Пошаговый процесс SAML

Таким образом три сущности — браузер, SP и IdP — договариваются о доступе. SP не нужно заранее ничего знать о пользователе, использующем браузер, ему достаточно доверять IdP.

Ethereum для пользователей SAML

Ethereum — это децентрализованная система.

Вход в Ethereum

У пользователей есть приватный ключ (обычно хранящийся в расширении браузера). Из приватного ключа можно получить публичный ключ, а из него — 20-байтовый адрес. Когда пользователям необходимо войти в систему, им предлагается подписать сообщение с помощью nonce (одноразового значения). Сервер может проверить, что подпись была создана этим адресом.

Получение дополнительных данных из аттестаций

Подпись подтверждает только адрес Ethereum. Чтобы получить другие атрибуты пользователя, обычно используются аттестации (opens in a new tab). Аттестация обычно содержит следующие поля:

  • Аттестующий, адрес, который сделал аттестацию
  • Получатель, адрес, к которому применяется аттестация
  • Данные, аттестуемые данные, такие как имя, разрешения и т. д.
  • Схема, идентификатор схемы, используемой для интерпретации данных.

Из-за децентрализованной природы Ethereum любой пользователь может делать аттестации. Личность аттестующего важна для определения того, какие аттестации мы считаем надежными.

Настройка

Первый шаг — обеспечить взаимодействие между SAML SP и SAML IdP.

  1. Загрузите программное обеспечение. Пример программного обеспечения для этой статьи находится на GitHub (opens in a new tab). Разные этапы хранятся в разных ветках, для этого этапа вам нужна ветка saml-only

    1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
    2cd 250420-saml-ethereum
    3pnpm install
  2. Создайте ключи с самоподписанными сертификатами. Это означает, что ключ является собственным центром сертификации и должен быть импортирован вручную поставщику услуг. Дополнительную информацию см. в документации OpenSSL (opens in a new tab).

    1mkdir keys
    2cd keys
    3openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
    4openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
    5cd ..
  3. Запустите серверы (и SP, и IdP)

    1pnpm start
  4. Перейдите по URL-адресу SP http://localhost:3000/ (opens in a new tab) и нажмите кнопку для перенаправления на IdP (порт 3001).

  5. Укажите свой адрес электронной почты в IdP и нажмите Login to the service provider. Убедитесь, что вы были перенаправлены обратно к поставщику услуг (порт 3000) и что он распознает вас по вашему адресу электронной почты.

Подробное объяснение

Вот что происходит, шаг за шагом:

Обычный вход в систему SAML без Ethereum

src/config.mts

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

1const fs = await import("fs")
2
3const protocol="http"

Пока мы просто тестируем, поэтому можно использовать HTTP.

1export const spCert = fs.readFileSync("keys/saml-sp.crt").toString()
2export const idpCert = fs.readFileSync("keys/saml-idp.crt").toString()

Прочтите публичные ключи, которые обычно доступны обоим компонентам (и либо доверяются напрямую, либо подписаны доверенным центром сертификации).

1export const spPort = 3000
2export const spHostname = "localhost"
3export const spDir = "sp"
4
5export const idpPort = 3001
6export const idpHostname = "localhost"
7export const idpDir = "idp"
8
9export const spUrl = `${protocol}://${spHostname}:${spPort}/${spDir}`
10export const idpUrl = `${protocol}://${idpHostname}:${idpPort}/${idpDir}`
Показать все

URL-адреса для обоих компонентов.

1export const spPublicData = {

Публичные данные для поставщика услуг.

1 entityID: `${spUrl}/metadata`,

По соглашению, в SAML entityID — это URL-адрес, по которому доступны метаданные сущности. Эти метаданные соответствуют здешним публичным данным, за исключением того, что они представлены в формате XML.

1 wantAssertionsSigned: true,
2 authnRequestsSigned: false,
3 signingCert: spCert,
4 allowCreate: true,
5 assertionConsumerService: [{
6 Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
7 Location: `${spUrl}/assertion`,
8 }]
9 }
Показать все

Самым важным определением для наших целей является assertionConsumerServer. Это означает, что для утверждения чего-либо (например, "пользователь, который отправляет вам эту информацию, — somebody@example.com (opens email client)") поставщику услуг нам нужно использовать HTTP POST (opens in a new tab) на URL-адрес http://localhost:3000/sp/assertion.

1export const idpPublicData = {
2 entityID: `${idpUrl}/metadata`,
3 signingCert: idpCert,
4 wantAuthnRequestsSigned: false,
5 singleSignOnService: [{
6 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
7 Location: `${idpUrl}/login`
8 }],
9 singleLogoutService: [{
10 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
11 Location: `${idpUrl}/logout`
12 }],
13 }
Показать все

Публичные данные для поставщика удостоверений похожи. В нем указано, что для входа пользователя в систему необходимо отправить POST-запрос на http://localhost:3001/idp/login, а для выхода — на http://localhost:3001/idp/logout.

src/sp.mts

Это код, который реализует поставщика услуг.

1import * as config from "./config.mts"
2const fs = await import("fs")
3const saml = await import("samlify")

Мы используем библиотеку samlify (opens in a new tab) для реализации SAML.

1import * as validator from "@authenio/samlify-node-xmllint"
2saml.setSchemaValidator(validator)

Библиотека samlify ожидает наличия пакета для проверки того, что XML-документ корректен, подписан ожидаемым публичным ключом и т. д. Для этой цели мы используем @authenio/samlify-node-xmllint (opens in a new tab).

1const express = (await import("express")).default
2const spRouter = express.Router()
3const app = express()

Router (opens in a new tab) из express (opens in a new tab) — это "мини-веб-сайт", который можно встроить в другой веб-сайт. В данном случае мы используем его для группировки всех определений поставщика услуг.

1const spPrivateKey = fs.readFileSync("keys/saml-sp.pem").toString()
2
3const sp = saml.ServiceProvider({
4 privateKey: spPrivateKey,
5 ...config.spPublicData
6})

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

1const idp = saml.IdentityProvider(config.idpPublicData);

Публичные данные содержат все, что поставщик услуг должен знать о поставщике удостоверений.

1spRouter.get(`/metadata`,
2 (req, res) => res.header("Content-Type", "text/xml").send(sp.getMetadata())
3)

Чтобы обеспечить совместимость с другими компонентами SAML, поставщики услуг и удостоверений должны предоставлять свои публичные данные (называемые метаданными) в формате XML по адресу /metadata.

1spRouter.post(`/assertion`,

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

1 async (req, res) => {
2 // console.log(`SAML response:\n${Buffer.from(req.body.SAMLResponse, 'base64').toString('utf-8')}`)

Вы можете использовать закомментированную команду, чтобы увидеть данные XML, предоставленные в утверждении. Они закодированы в base64 (opens in a new tab).

1 try {
2 const loginResponse = await sp.parseLoginResponse(idp, 'post', req);"

Проанализируйте запрос на вход от сервера удостоверений.

1 res.send(`
2 <html>
3 <body>
4 <h2>Hello ${loginResponse.extract.nameID}</h2>
5 </body>
6 </html>
7 `)
8 res.send();

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

1 } catch (err) {
2 console.error('Error processing SAML response:', err);
3 res.status(400).send('SAML authentication failed');
4 }
5 }
6)

Сообщите пользователю в случае сбоя.

1spRouter.get('/login',

Создайте запрос на вход, когда браузер пытается получить эту страницу. Это обработчик для шага 1 на диаграмме последовательности выше.

1 async (req, res) => {
2 const loginRequest = await sp.createLoginRequest(idp, "post")

Получите информацию для отправки запроса на вход.

1 res.send(`
2 <html>
3 <body>
4 <script>
5 window.onload = function () { document.forms[0].submit(); }
6 </script>

Эта страница автоматически отправляет форму (см. ниже). Таким образом, пользователю не нужно ничего делать для перенаправления. Это шаг 2 на диаграмме последовательности выше.

1 <form method="post" action="${loginRequest.entityEndpoint}">

Отправьте POST-запрос на loginRequest.entityEndpoint (URL-адрес конечной точки поставщика удостоверений).

1 <input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}" />

Имя поля ввода — loginRequest.type (SAMLRequest). Содержимое этого поля — loginRequest.context, что снова является XML, закодированным в base64.

1 </form>
2 </body>
3 </html>
4 `)
5 }
6)
7
8app.use(express.urlencoded({extended: true}))

Это промежуточное ПО (opens in a new tab) читает тело HTTP-запроса (opens in a new tab). По умолчанию express игнорирует его, потому что большинство запросов этого не требуют. Нам это нужно, потому что POST использует тело запроса.

1app.use(`/${config.spDir}`, spRouter)

Подключите маршрутизатор в каталоге поставщика услуг (/sp).

1app.get("/", (req, res) => {
2 res.send(`
3 <html>
4 <body>
5 <button onClick="document.location.href='${config.spUrl}/login'">
6 Нажмите здесь, чтобы войти
7 </button>
8 </body>
9 </html>
10 `)
11})
Показать все

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

1app.listen(config.spPort, () => {
2 console.log(`service provider is running on http://${config.spHostname}:${config.spPort}`)
3})

Прослушивайте spPort с помощью этого приложения express.

src/idp.mts

Это поставщик удостоверений. Он очень похож на поставщика услуг, приведенные ниже объяснения относятся к тем частям, которые отличаются.

1const xmlParser = new (await import("fast-xml-parser")).XMLParser(
2 {
3 ignoreAttributes: false, // Сохранять атрибуты
4 attributeNamePrefix: "@_", // Префикс для атрибутов
5 }
6)

Нам нужно прочитать и понять XML-запрос, который мы получаем от поставщика услуг.

1const getLoginPage = requestId => `

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

1<html>
2 <head>
3 <title>Страница входа</title>
4 </head>
5 <body>
6 <h2>Страница входа</h2>
7 <form method="post" action="./loginSubmitted">
8 <input type="hidden" name="requestId" value="${requestId}" />
9 Адрес электронной почты: <input name="email" />
10 <br />
11 <button type="Submit">
12 Войти в систему поставщика услуг
13 </button>
Показать все

Мы отправляем поставщику услуг два поля:

  1. requestId, на который мы отвечаем.
  2. Идентификатор пользователя (пока мы используем адрес электронной почты, предоставленный пользователем).
1 </form>
2 </body>
3</html>
4
5const idpRouter = express.Router()
6
7idpRouter.post("/loginSubmitted", async (req, res) => {
8 const loginResponse = await idp.createLoginResponse(

Это обработчик для шага 5 на диаграмме последовательности выше. idp.createLoginResponse (opens in a new tab) создает ответ на запрос входа.

1 sp,
2 {
3 authnContextClassRef: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
4 audience: sp.entityID,

Аудитория — это поставщик услуг.

1 extract: {
2 request: {
3 id: req.body.requestId
4 }
5 },

Информация, извлеченная из запроса. Единственный параметр, который нас интересует в запросе, — это requestId, который позволяет поставщику услуг сопоставлять запросы и ответы на них.

1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // Обеспечить подпись

Нам нужен signingKey для получения данных для подписи ответа. Поставщик услуг не доверяет неподписанным запросам.

1 },
2 "post",
3 {
4 email: req.body.email

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

1 }
2 );
3
4 res.send(`
5 <html>
6 <body>
7 <script>
8 window.onload = function () { document.forms[0].submit(); }
9 </script>
10
11 <form method="post" action="${loginResponse.entityEndpoint}">
12 <input type="hidden" name="${loginResponse.type}" value="${loginResponse.context}" />
13 </form>
14 </body>
15 </html>
16 `)
17})
Показать все

Опять же, используйте автоматически отправляемую форму. Это шаг 6 на диаграмме последовательности выше.

1
2// Конечная точка IdP для запросов на вход
3idpRouter.post(`/login`,

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

1 async (req, res) => {
2 try {
3 // Обходной путь, потому что мне не удалось заставить parseLoginRequest работать.
4 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
5 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
6 res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))

Мы должны иметь возможность использовать idp.parseLoginRequest (opens in a new tab) для чтения ID запроса на аутентификацию. Однако мне не удалось заставить его работать, и не стоило тратить на это много времени, поэтому я просто использую XML-парсер общего назначения (opens in a new tab). Информация, которая нам нужна, — это атрибут ID внутри тега <samlp:AuthnRequest>, который находится на верхнем уровне XML.

Использование подписей Ethereum

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

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

Чтобы увидеть это в действии, остановите существующие IdP и SP и выполните следующие команды:

1git checkout eth-signatures
2pnpm install
3pnpm start

Затем перейдите к SP (opens in a new tab) и следуйте инструкциям.

Обратите внимание, что на данном этапе мы не знаем, как получить адрес электронной почты из адреса Ethereum, поэтому вместо этого мы сообщаем SP <адрес Ethereum>@bad.email.address.

Подробное объяснение

Изменения касаются шагов 4-5 на предыдущей диаграмме.

SAML с подписью Ethereum

Единственный файл, который мы изменили, — idp.mts. Вот измененные части.

1import { v4 as uuidv4 } from 'uuid'
2import { verifyMessage } from 'viem'

Нам нужны эти две дополнительные библиотеки. Мы используем uuid (opens in a new tab) для создания значения nonce (opens in a new tab). Само значение не имеет значения, важен лишь тот факт, что оно используется только один раз.

Библиотека viem (opens in a new tab) позволяет нам использовать определения Ethereum. Здесь она нужна нам для проверки действительности подписи.

1const loginPrompt = "Чтобы получить доступ к поставщику услуг, подпишите этот nonce: "

Кошелек запрашивает у пользователя разрешение на подписание сообщения. Сообщение, которое является просто nonce, может сбить с толку пользователей, поэтому мы включаем это приглашение.

1// Храните здесь requestID
2let nonces = {}

Нам нужна информация о запросе, чтобы иметь возможность ответить на него. Мы могли бы отправить ее с запросом (шаг 4) и получить обратно (шаг 5). Однако мы не можем доверять информации, которую получаем от браузера, находящегося под контролем потенциально враждебного пользователя. Поэтому лучше хранить ее здесь, используя nonce в качестве ключа.

Обратите внимание, что для простоты мы делаем это здесь в виде переменной. Однако у этого есть несколько недостатков:

  • Мы уязвимы для атаки типа «отказ в обслуживании». Злонамеренный пользователь может попытаться войти в систему несколько раз, заполнив нашу память.
  • Если процесс IdP необходимо перезапустить, мы потеряем существующие значения.
  • Мы не можем распределять нагрузку между несколькими процессами, потому что у каждого будет своя переменная.

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

1const getSignaturePage = requestId => {
2 const nonce = uuidv4()
3 nonces[nonce] = requestId

Создайте nonce и сохраните requestId для будущего использования.

1 return `
2<html>
3 <head>
4 <script type="module">

Этот JavaScript выполняется автоматически при загрузке страницы.

1 import { createWalletClient, custom, getAddress } from 'https://esm.sh/viem'

Нам нужно несколько функций из viem.

1 if (!window.ethereum) {
2 alert("Пожалуйста, установите MetaMask или совместимый кошелек, а затем перезагрузите страницу")
3 }

Мы можем работать, только если в браузере есть кошелек.

1 const [account] = await window.ethereum.request({method: 'eth_requestAccounts'})

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

1 const walletClient = createWalletClient({
2 account,
3 transport: custom(window.ethereum)
4 })

Создайте клиент кошелька (opens in a new tab) для взаимодействия с кошельком браузера.

1 window.goodSignature = () => {
2 walletClient.signMessage({
3 message: "${loginPrompt}${nonce}"

Попросите пользователя подписать сообщение. Поскольку весь этот HTML-код находится в шаблонной строке (opens in a new tab), мы можем использовать переменные, определенные в процессе idp. Это шаг 4.5 на диаграмме последовательности.

1 }).then(signature => {
2 const path= "/${config.idpDir}/signature/${nonce}/" + account + "/" + signature
3 window.location.href = path
4 })
5 }

Перенаправление на /idp/signature/<nonce>/<адрес>/<подпись>. Это шаг 5 на диаграмме последовательности.

1 window.badSignature = () => {
2 const path= "/${config.idpDir}/signature/${nonce}/" +
3 getAddress("0x" + "BAD060A7".padEnd(40, "0")) +
4 "/0x" + "BAD0516".padStart(130, "0")
5 window.location.href = path
6 }

Подпись отправляется обратно браузером, который потенциально является злонамеренным (ничто не мешает вам просто открыть http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature в браузере). Поэтому важно убедиться, что процесс IdP правильно обрабатывает неверные подписи.

1 </script>
2 </head>
3 <body>
4 <h2>Пожалуйста, подпишите</h2>
5 <button onClick="window.goodSignature()">
6 Отправить правильную (действительную) подпись
7 </button>
8 <br/>
9 <button onClick="window.badSignature()">
10 Отправить неверную (недействительную) подпись
11 </button>
12 </body>
13</html>
14`
15}
Показать все

Остальное — просто стандартный HTML.

1idpRouter.get("/signature/:nonce/:account/:signature", async (req, res) => {

Это обработчик для шага 5 на диаграмме последовательности.

1 const requestId = nonces[req.params.nonce]
2 if (requestId === undefined) {
3 res.send("Bad nonce")
4 return ;
5 }
6
7 nonces[req.params.nonce] = undefined

Получите ID запроса и удалите nonce из nonces, чтобы убедиться, что его нельзя использовать повторно.

1 try {

Поскольку существует так много способов, которыми подпись может быть недействительной, мы оборачиваем это в try ... блок catch, чтобы перехватить любые возникающие ошибки.

1 const validSignature = await verifyMessage({
2 address: req.params.account,
3 message: `${loginPrompt}${req.params.nonce}`,
4 signature: req.params.signature
5 })

Используйте verifyMessage (opens in a new tab) для реализации шага 5.5 на диаграмме последовательности.

1 if (!validSignature)
2 throw("Bad signature")
3 } catch (err) {
4 res.send("Error:" + err)
5 return ;
6 }

Остальная часть обработчика эквивалентна тому, что мы делали в обработчике /loginSubmitted ранее, за исключением одного небольшого изменения.

1 const loginResponse = await idp.createLoginResponse(
2 .
3 .
4 .
5 {
6 email: req.params.account + "@bad.email.address"
7 }
8 );

У нас нет фактического адреса электронной почты (мы получим его в следующем разделе), поэтому пока мы возвращаем адрес Ethereum и четко помечаем его как не являющийся адресом электронной почты.

1// Конечная точка IdP для запросов на вход
2idpRouter.post(`/login`,
3 async (req, res) => {
4 try {
5 // Обходной путь, потому что мне не удалось заставить parseLoginRequest работать.
6 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
7 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
8 res.send(getSignaturePage(samlRequest["samlp:AuthnRequest"]["@_ID"]))
9 } catch (err) {
10 console.error('Error processing SAML response:', err);
11 res.status(400).send('SAML authentication failed');
12 }
13 }
14)
Показать все

Вместо getLoginPage теперь используйте getSignaturePage в обработчике шага 3.

Получение адреса электронной почты

Следующий шаг — получение адреса электронной почты, идентификатора, запрошенного поставщиком услуг. Для этого мы используем Ethereum Attestation Service (EAS) (opens in a new tab).

Самый простой способ получить аттестации — использовать GraphQL API (opens in a new tab). Мы используем этот запрос:

1query GetAttestationsByRecipient {
2 attestations(
3 where: {
4 recipient: { equals: "${getAddress(ethAddr)}" }
5 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }
6 }
7 take: 1
8 ) {
9 data
10 id
11 attester
12 }
13}
Показать все

Этот schemaId (opens in a new tab) включает только адрес электронной почты. Этот запрос запрашивает аттестации этой схемы. Субъект аттестации называется recipient. Это всегда адрес Ethereum.

Внимание: способ получения аттестаций здесь имеет две проблемы с безопасностью.

  • Мы обращаемся к конечной точке API, https://optimism.easscan.org/graphql, которая является централизованным компонентом. Мы можем получить атрибут id, а затем выполнить поиск он-чейн, чтобы проверить, что аттестация реальна, но конечная точка API все еще может подвергать цензуре аттестации, не сообщая нам о них.

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

  • Мы не смотрим на личность аттестующего. Любой может предоставить нам ложную информацию. В реальной реализации у нас был бы набор доверенных аттестующих, и мы бы рассматривали только их аттестации.

Чтобы увидеть это в действии, остановите существующие IdP и SP и выполните следующие команды:

1git checkout email-address
2pnpm install
3pnpm start

Затем укажите свой адрес электронной почты. У вас есть два способа сделать это:

  • Импортируйте кошелек, используя приватный ключ, и используйте тестовый приватный ключ 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

  • Добавьте аттестацию для своего собственного адреса электронной почты:

    1. Перейдите к схеме в обозревателе аттестаций (opens in a new tab).

    2. Нажмите Attest with Schema.

    3. Введите свой адрес Ethereum в качестве получателя, свой адрес электронной почты в качестве адреса электронной почты и выберите Onchain. Затем нажмите Make Attestation.

    4. Одобрите транзакцию в своем кошельке. Вам понадобится немного ETH в блокчейне Optimism (opens in a new tab), чтобы заплатить за газ.

В любом случае, после этого перейдите по адресу http://localhost:3000 (opens in a new tab) и следуйте инструкциям. Если вы импортировали тестовый приватный ключ, вы получите электронное письмо test_addr_0@example.com. Если вы использовали свой собственный адрес, это должно быть то, что вы аттестовали.

Подробное объяснение

Получение электронной почты из адреса Ethereum

Новые шаги — это взаимодействие GraphQL, шаги 5.6 и 5.7.

И снова, вот измененные части idp.mts.

1import { GraphQLClient } from 'graphql-request'
2import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk'

Импортируйте необходимые нам библиотеки.

1const graphqlEndpointUrl = "https://optimism.easscan.org/graphql"

Для каждого блокчейна существует отдельная конечная точка (opens in a new tab).

1const graphqlClient = new GraphQLClient(graphqlEndpointUrl, { fetch })

Создайте новый клиент GraphQLClient, который мы можем использовать для запросов к конечной точке.

1const graphqlSchema = 'string emailAddress'
2const graphqlEncoder = new SchemaEncoder(graphqlSchema)

GraphQL дает нам только непрозрачный объект данных с байтами. Чтобы понять его, нам нужна схема.

1const ethereumAddressToEmail = async ethAddr => {

Функция для получения адреса электронной почты из адреса Ethereum.

1 const query = `
2 query GetAttestationsByRecipient {

Это GraphQL-запрос.

1 attestations(

Мы ищем аттестации.

1 where: {
2 recipient: { equals: "${getAddress(ethAddr)}" }
3 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }
4 }

Аттестации, которые нам нужны, — это те, что находятся в нашей схеме, где получателем является getAddress(ethAddr). Функция getAddress (opens in a new tab) гарантирует, что наш адрес имеет правильную контрольную сумму (opens in a new tab). Это необходимо, поскольку GraphQL чувствителен к регистру. "0xBAD060A7", "0xBad060A7" и "0xbad060a7" — это разные значения.

1 take: 1

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

1 ) {
2 data
3 id
4 attester
5 }
6 }`

Поля, которые мы хотим получить.

  • attester: адрес, который отправил аттестацию. Обычно это используется для принятия решения о том, доверять ли аттестации.
  • id: идентификатор аттестации. Вы можете использовать это значение, чтобы прочитать аттестацию он-чейн (opens in a new tab) и проверить, верна ли информация из запроса GraphQL.
  • data: данные схемы (в данном случае адрес электронной почты).
1 const queryResult = await graphqlClient.request(query)
2
3 if (queryResult.attestations.length == 0)
4 return "no_address@available.is"

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

1 const attestationDataFields = graphqlEncoder.decodeData(queryResult.attestations[0].data)
2 return attestationDataFields[0].value.value
3}

Если значение есть, используйте decodeData для его декодирования. Нам не нужны предоставляемые метаданные, только само значение.

1 const loginResponse = await idp.createLoginResponse(
2 sp,
3 {
4 .
5 .
6 .
7 },
8 "post",
9 {
10 email: await ethereumAddressToEmail(req.params.account)
11 }
12 );
Показать все

Используйте новую функцию для получения адреса электронной почты.

Что насчет децентрализации?

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

Возможно, есть решение с использованием многосторонних вычислений (MPC) (opens in a new tab). Я надеюсь написать об этом в будущем руководстве.

Заключение

Внедрение стандарта входа в систему, такого как подписи Ethereum, сталкивается с проблемой «курицы и яйца». Поставщики услуг хотят привлечь как можно более широкий рынок. Пользователи хотят иметь возможность получать доступ к услугам, не беспокоясь о поддержке своего стандарта входа. Создание адаптеров, таких как Ethereum IdP, может помочь нам преодолеть это препятствие.

Больше моих работ смотрите здесь (opens in a new tab).

Последнее обновление страницы: 23 ноября 2025 г.

Было ли это руководство полезным?