Przejdź do głównej zawartości

Używanie Ethereum do uwierzytelniania web2

web2
uwierzytelnianie
eas
Początkujący
Ori Pomerantz
30 kwietnia 2025
18 minuta czytania

Wprowadzenie

SAML (opens in a new tab) to standard używany w web2, który pozwala dostawcy tożsamości (IdP) (opens in a new tab) na dostarczanie informacji o użytkowniku dostawcom usług (SP) (opens in a new tab).

W tym samouczku dowiesz się, jak zintegrować podpisy Ethereum z SAML, aby umożliwić użytkownikom używanie ich portfeli Ethereum do uwierzytelniania się w usługach web2, które jeszcze nie obsługują natywnie Ethereum.

Pamiętaj, że ten samouczek został napisany dla dwóch różnych grup odbiorców:

  • Ludzie z kręgu Ethereum, którzy rozumieją Ethereum i potrzebują nauczyć się SAML
  • Osoby z Web2, które rozumieją SAML i uwierzytelnianie web2 i muszą nauczyć się Ethereum

W rezultacie będzie on zawierał wiele materiałów wprowadzających, które już znasz. Możesz je pominąć.

SAML dla osób z kręgu Ethereum

SAML to scentralizowany protokół. Dostawca usług (SP) akceptuje asercje (takie jak "to jest mój użytkownik Jan, powinien mieć uprawnienia do wykonywania A, B i C") od dostawcy tożsamości (IdP) tylko wtedy, gdy ma z nim wcześniej ustaloną relację zaufania lub z urzędem certyfikacji (opens in a new tab), który podpisał certyfikat tegoż IdP.

Na przykład SP może być biurem podróży świadczącym usługi turystyczne dla firm, a IdP może być wewnętrzną stroną internetową firmy. Gdy pracownicy muszą zarezerwować podróż służbową, biuro podróży wysyła ich do uwierzytelnienia przez firmę, zanim pozwoli im faktycznie zarezerwować podróż.

Proces SAML krok po kroku

W ten sposób trzy podmioty, przeglądarka, SP i IdP, negocjują dostęp. SP nie musi z góry wiedzieć nic o użytkowniku korzystającym z przeglądarki, wystarczy, że ufa IdP.

Ethereum dla osób z kręgu SAML

Ethereum to system zdecentralizowany.

Logowanie Ethereum

Użytkownicy posiadają klucz prywatny (zazwyczaj przechowywany w rozszerzeniu przeglądarki). Z klucza prywatnego można wyprowadzić klucz publiczny, a z niego 20-bajtowy adres. Gdy użytkownicy muszą zalogować się do systemu, są proszeni o podpisanie wiadomości z nonce (wartością jednorazowego użytku). Serwer może zweryfikować, czy podpis został utworzony przez ten adres.

Pobieranie dodatkowych danych z poświadczeń

Podpis weryfikuje tylko adres Ethereum. Aby uzyskać inne atrybuty użytkownika, zazwyczaj używa się poświadczeń (opens in a new tab). Poświadczenie zazwyczaj zawiera następujące pola:

  • Poświadczający, adres, który dokonał poświadczenia
  • Odbiorca, adres, którego dotyczy poświadczenie
  • Dane, czyli dane, które są poświadczane, takie jak imię, uprawnienia itp.
  • Schemat, identyfikator schematu używanego do interpretacji danych.

Ze względu na zdecentralizowany charakter Ethereum każdy użytkownik może tworzyć poświadczenia. Tożsamość poświadczającego jest ważna, aby zidentyfikować, które poświadczenia uważamy za wiarygodne.

Konfiguracja

Pierwszym krokiem jest zapewnienie komunikacji pomiędzy SAML SP i SAML IdP.

  1. Pobierz oprogramowanie. Przykładowe oprogramowanie do tego artykułu znajduje się na githubie (opens in a new tab). Różne etapy są przechowywane w różnych gałęziach, na tym etapie potrzebujesz gałęzi saml-only

    1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
    2cd 250420-saml-ethereum
    3pnpm install
  2. Utwórz klucze z certyfikatami z podpisem własnym. Oznacza to, że klucz jest swoim własnym urzędem certyfikacji i musi zostać ręcznie zaimportowany do dostawcy usług. Więcej informacji można znaleźć w dokumentacji 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. Uruchom serwery (zarówno SP, jak i IdP)

    1pnpm start
  4. Przejdź do SP pod adresem URL http://localhost:3000/ (opens in a new tab) i kliknij przycisk, aby zostać przekierowanym do IdP (port 3001).

  5. Podaj IdP swój adres e-mail i kliknij Zaloguj się do dostawcy usług. Zobaczysz, że zostaniesz przekierowany z powrotem do dostawcy usług (port 3000) i że zna Cię on po Twoim adresie e-mail.

Szczegółowe wyjaśnienie

Oto co się dzieje, krok po kroku:

Normalne logowanie SAML bez Ethereum

src/config.mts

Ten plik zawiera konfigurację zarówno dla dostawcy tożsamości, jak i dostawcy usług. Zwykle byłyby to dwa różne podmioty, ale tutaj dla uproszczenia możemy współdzielić kod.

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

Na razie tylko testujemy, więc możemy używać HTTP.

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

Odczytaj klucze publiczne, które są normalnie dostępne dla obu komponentów (i są albo bezpośrednio zaufane, albo podpisane przez zaufany urząd certyfikacji).

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}`
Pokaż wszystko

Adresy URL dla obu komponentów.

1export const spPublicData = {

Dane publiczne dla dostawcy usług.

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

Zgodnie z konwencją, w SAML entityID to adres URL, pod którym dostępne są metadane podmiotu. Te metadane odpowiadają danym publicznym tutaj, z tą różnicą, że są w formie 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 }
Pokaż wszystko

Najważniejszą definicją dla naszych celów jest assertionConsumerServer. Oznacza to, że aby potwierdzić coś (na przykład, "użytkownik, który wysyła Ci te informacje, to somebody@example.com (opens email client)") dostawcy usług, musimy użyć metody HTTP POST (opens in a new tab) na adres 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 }
Pokaż wszystko

Dane publiczne dostawcy tożsamości są podobne. Określa on, że aby zalogować użytkownika, należy wysłać żądanie POST na adres http://localhost:3001/idp/login, a aby go wylogować, na adres http://localhost:3001/idp/logout.

src/sp.mts

To jest kod, który implementuje dostawcę usług.

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

Do implementacji SAML używamy biblioteki samlify (opens in a new tab).

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

Biblioteka samlify oczekuje, że pakiet zweryfikuje, czy kod XML jest poprawny, podpisany oczekiwanym kluczem publicznym itp. Do tego celu używamy @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) z express (opens in a new tab) to "mini strona internetowa", którą można zamontować wewnątrz strony internetowej. W tym przypadku używamy go do zgrupowania wszystkich definicji dostawcy usług.

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

Własna reprezentacja dostawcy usług to wszystkie dane publiczne oraz klucz prywatny, którego używa do podpisywania informacji.

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

Dane publiczne zawierają wszystko, co dostawca usług musi wiedzieć o dostawcy tożsamości.

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

Aby umożliwić interoperacyjność z innymi komponentami SAML, dostawcy usług i tożsamości powinni udostępniać swoje dane publiczne (zwane metadanymi) w formacie XML pod adresem /metadata.

1spRouter.post(`/assertion`,

Jest to strona, do której przeglądarka uzyskuje dostęp w celu identyfikacji. Asercja zawiera identyfikator użytkownika (tutaj używamy adresu e-mail) i może zawierać dodatkowe atrybuty. To jest procedura obsługi kroku 7 na powyższym diagramie sekwencji.

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

Możesz użyć polecenia w komentarzu, aby zobaczyć dane XML podane w asercji. Są one zakodowane w base64 (opens in a new tab).

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

Analizuj żądanie logowania z serwera tożsamości.

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

Wyślij odpowiedź HTML, aby pokazać użytkownikowi, że otrzymaliśmy dane logowania.

1 } catch (err) {
2 console.error('Błąd przetwarzania odpowiedzi SAML:', err);
3 res.status(400).send('Uwierzytelnianie SAML nie powiodło się');
4 }
5 }
6)

Poinformuj użytkownika w przypadku niepowodzenia.

1spRouter.get('/login',

Utwórz żądanie logowania, gdy przeglądarka spróbuje uzyskać dostęp do tej strony. To jest procedura obsługi kroku 1 na powyższym diagramie sekwencji.

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

Pobierz informacje, aby wysłać żądanie logowania.

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

Ta strona automatycznie przesyła formularz (patrz poniżej). W ten sposób użytkownik nie musi nic robić, aby zostać przekierowanym. To jest krok 2 na powyższym diagramie sekwencji.

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

Wyślij żądanie POST do loginRequest.entityEndpoint (adres URL punktu końcowego dostawcy tożsamości).

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

Nazwa danych wejściowych to loginRequest.type (SAMLRequest). Zawartością tego pola jest loginRequest.context, który jest ponownie kodem XML zakodowanym w base64.

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

Ten middleware (opens in a new tab) odczytuje treść żądania HTTP (opens in a new tab). Domyślnie express je ignoruje, ponieważ większość żądań go nie wymaga. Potrzebujemy go, ponieważ POST używa treści.

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

Zamontuj router w katalogu dostawcy usług (/sp).

1app.get("/", (req, res) => {
2 res.send(`
3 <html>
4 <body>
5 <button onClick="document.location.href='${config.spUrl}/login'">
6 Kliknij tutaj, aby się zalogować
7 </button>
8 </body>
9 </html>
10 `)
11})
Pokaż wszystko

Jeśli przeglądarka spróbuje uzyskać dostęp do katalogu głównego, udostępnij jej link do strony logowania.

1app.listen(config.spPort, () => {
2 console.log(`dostawca usług działa na http://${config.spHostname}:${config.spPort}`)
3})

Nasłuchuj na spPort za pomocą tej aplikacji express.

src/idp.mts

To jest dostawca tożsamości. Jest on bardzo podobny do dostawcy usług, poniższe wyjaśnienia dotyczą części, które się różnią.

1const xmlParser = new (await import("fast-xml-parser")).XMLParser(
2 {
3 ignoreAttributes: false, // Zachowaj atrybuty
4 attributeNamePrefix: "@_", // Prefiks dla atrybutów
5 }
6)

Musimy odczytać i zrozumieć żądanie XML, które otrzymujemy od dostawcy usług.

1const getLoginPage = requestId => `

Ta funkcja tworzy stronę z automatycznie przesyłanym formularzem, która jest zwracana w kroku 4 powyższego diagramu sekwencji.

1<html>
2 <head>
3 <title>Strona logowania</title>
4 </head>
5 <body>
6 <h2>Strona logowania</h2>
7 <form method="post" action="./loginSubmitted">
8 <input type="hidden" name="requestId" value="${requestId}" />
9 Adres e-mail: <input name="email" />
10 <br />
11 <button type="Submit">
12 Zaloguj się do dostawcy usług
13 </button>
Pokaż wszystko

Są dwa pola, które wysyłamy do dostawcy usług:

  1. requestId, na który odpowiadamy.
  2. Identyfikator użytkownika (na razie używamy adresu e-mail podanego przez użytkownika).
1 </form>
2 </body>
3</html>
4
5const idpRouter = express.Router()
6
7idpRouter.post("/loginSubmitted", async (req, res) => {
8 const loginResponse = await idp.createLoginResponse(

To jest procedura obsługi kroku 5 na powyższym diagramie sekwencji. idp.createLoginResponse (opens in a new tab) tworzy odpowiedź na żądanie logowania.

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

Odbiorcą jest dostawca usług.

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

Informacje wyodrębnione z żądania. Jedynym parametrem w żądaniu, który nas interesuje, jest requestId, który pozwala dostawcy usług dopasować żądania i ich odpowiedzi.

1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // Zapewnij podpisywanie

Potrzebujemy signingKey, aby mieć dane do podpisania odpowiedzi. Dostawca usług nie ufa niepodpisanym żądaniom.

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

To jest pole z informacjami o użytkowniku, które odsyłamy do dostawcy usług.

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})
Pokaż wszystko

Ponownie, użyj automatycznie przesyłanego formularza. To jest krok 6 na powyższym diagramie sekwencji.

1
2// Punkt końcowy IdP dla żądań logowania
3idpRouter.post(`/login`,

To jest punkt końcowy, który odbiera żądanie logowania od dostawcy usług. To jest procedura obsługi kroku 3 na powyższym diagramie sekwencji.

1 async (req, res) => {
2 try {
3 // Obejście, ponieważ nie mogłem sprawić, by parseLoginRequest zadziałało.
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"]))

Powinniśmy być w stanie użyć idp.parseLoginRequest (opens in a new tab), aby odczytać ID żądania uwierzytelnienia. Jednak nie udało mi się go uruchomić i nie było warto poświęcać na to dużo czasu, więc użyłem uniwersalnego parsera XML (opens in a new tab). Informacją, której potrzebujemy, jest atrybut ID wewnątrz tagu <samlp:AuthnRequest>, który znajduje się na najwyższym poziomie XML.

Używanie podpisów Ethereum

Teraz, gdy możemy wysłać tożsamość użytkownika do dostawcy usług, następnym krokiem jest uzyskanie tożsamości użytkownika w zaufany sposób. Viem pozwala nam po prostu poprosić portfel o adres użytkownika, ale oznacza to proszenie przeglądarki o informacje. Nie kontrolujemy przeglądarki, więc nie możemy automatycznie ufać odpowiedzi, którą od niej otrzymujemy.

Zamiast tego IdP wyśle przeglądarce ciąg do podpisania. Jeśli portfel w przeglądarce podpisze ten ciąg, oznacza to, że to naprawdę ten adres (tzn. zna on klucz prywatny, który odpowiada temu adresowi).

Aby zobaczyć to w działaniu, zatrzymaj istniejące IdP i SP i uruchom te polecenia:

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

Następnie przejdź do SP (opens in a new tab) i postępuj zgodnie z instrukcjami.

Zauważ, że w tym momencie nie wiemy, jak uzyskać adres e-mail z adresu Ethereum, więc zamiast tego zgłaszamy <adres ethereum>@bad.email.address do SP.

Szczegółowe wyjaśnienie

Zmiany dotyczą kroków 4–5 na poprzednim diagramie.

SAML z podpisem Ethereum

Jedynym plikiem, który zmieniliśmy, jest idp.mts. Oto zmienione części.

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

Potrzebujemy tych dwóch dodatkowych bibliotek. Używamy uuid (opens in a new tab) do tworzenia wartości nonce (opens in a new tab). Sama wartość nie ma znaczenia, tylko fakt, że jest używana tylko raz.

Biblioteka viem (opens in a new tab) pozwala nam używać definicji Ethereum. Tutaj potrzebujemy jej do zweryfikowania, czy podpis jest rzeczywiście ważny.

1const loginPrompt = "Aby uzyskać dostęp do dostawcy usług, podpisz ten nonce: "

Portfel prosi użytkownika o pozwolenie na podpisanie wiadomości. Wiadomość, która jest tylko nonce, może dezorientować użytkowników, dlatego dołączamy ten monit.

1// Trzymaj tutaj requestID
2let nonces = {}

Potrzebujemy informacji o żądaniu, aby móc na nie odpowiedzieć. Moglibyśmy wysłać je z żądaniem (krok 4) i otrzymać je z powrotem (krok 5). Jednak nie możemy ufać informacjom, które otrzymujemy z przeglądarki, która jest pod kontrolą potencjalnie wrogiego użytkownika. Więc lepiej przechowywać je tutaj, z nonce jako kluczem.

Zauważ, że dla uproszczenia robimy to tutaj jako zmienną. Ma to jednak kilka wad:

  • Jesteśmy podatni na atak typu „odmowa usługi”. Złośliwy użytkownik mógłby próbować logować się wielokrotnie, zapełniając naszą pamięć.
  • Jeśli proces IdP musi zostać ponownie uruchomiony, tracimy istniejące wartości.
  • Nie możemy równoważyć obciążenia między wieloma procesami, ponieważ każdy z nich miałby swoją własną zmienną.

W systemie produkcyjnym użylibyśmy bazy danych i zaimplementowali jakiś mechanizm wygasania.

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

Utwórz nonce i przechowaj requestId do przyszłego użytku.

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

Ten kod JavaScript jest wykonywany automatycznie po załadowaniu strony.

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

Potrzebujemy kilku funkcji z viem.

1 if (!window.ethereum) {
2 alert("Zainstaluj MetaMask lub kompatybilny portfel, a następnie odśwież stronę")
3 }

Możemy pracować tylko wtedy, gdy w przeglądarce jest portfel.

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

Poproś o listę kont z portfela (window.ethereum). Załóż, że jest co najmniej jedno i przechowuj tylko pierwsze.

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

Utwórz klienta portfela (opens in a new tab) do interakcji z portfelem przeglądarki.

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

Poproś użytkownika o podpisanie wiadomości. Ponieważ cały ten kod HTML znajduje się w ciągu szablonu (opens in a new tab), możemy używać zmiennych zdefiniowanych w procesie idp. To jest krok 4.5 na diagramie sekwencji.

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

Przekieruj do /idp/signature/<nonce>/<adres>/<podpis>. To jest krok 5 na diagramie sekwencji.

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 }

Podpis jest odsyłany przez przeglądarkę, która jest potencjalnie złośliwa (nic nie stoi na przeszkodzie, aby po prostu otworzyć http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature w przeglądarce). Dlatego ważne jest, aby zweryfikować, czy proces IdP poprawnie obsługuje złe podpisy.

1 </script>
2 </head>
3 <body>
4 <h2>Proszę podpisać</h2>
5 <button onClick="window.goodSignature()">
6 Prześlij dobry (ważny) podpis
7 </button>
8 <br/>
9 <button onClick="window.badSignature()">
10 Prześlij zły (nieważny) podpis
11 </button>
12 </body>
13</html>
14`
15}
Pokaż wszystko

Reszta to standardowy HTML.

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

To jest procedura obsługi kroku 5 na diagramie sekwencji.

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

Pobierz identyfikator żądania i usuń nonce z nonces, aby upewnić się, że nie można go ponownie użyć.

1 try {

Ponieważ istnieje tak wiele sposobów, w jakie podpis może być nieważny, opakowujemy to w try... blok catch, aby przechwycić wszelkie zgłoszone błędy.

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

Użyj verifyMessage (opens in a new tab) do zaimplementowania kroku 5.5 na diagramie sekwencji.

1 if (!validSignature)
2 throw("Zły podpis")
3 } catch (err) {
4 res.send("Błąd:" + err)
5 return ;
6 }

Reszta procedury obsługi jest równoważna z tym, co zrobiliśmy wcześniej w procedurze obsługi /loginSubmitted, z wyjątkiem jednej małej zmiany.

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

Nie mamy rzeczywistego adresu e-mail (uzyskamy go w następnej sekcji), więc na razie zwracamy adres Ethereum i wyraźnie oznaczamy go jako niebędący adresem e-mail.

1// Punkt końcowy IdP dla żądań logowania
2idpRouter.post(`/login`,
3 async (req, res) => {
4 try {
5 // Obejście, ponieważ nie mogłem sprawić, by parseLoginRequest zadziałało.
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('Błąd przetwarzania odpowiedzi SAML:', err);
11 res.status(400).send('Uwierzytelnianie SAML nie powiodło się');
12 }
13 }
14)
Pokaż wszystko

Zamiast getLoginPage, użyj teraz getSignaturePage w procedurze obsługi kroku 3.

Uzyskiwanie adresu e-mail

Następnym krokiem jest uzyskanie adresu e-mail, identyfikatora wymaganego przez dostawcę usług. Aby to zrobić, używamy Ethereum Attestation Service (EAS) (opens in a new tab).

Najłatwiejszym sposobem na uzyskanie poświadczeń jest użycie API GraphQL (opens in a new tab). Używamy tego zapytania:

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}
Pokaż wszystko

Ten schemaId (opens in a new tab) zawiera tylko adres e-mail. To zapytanie prosi o poświadczenia tego schematu. Temat poświadczenia nazywa się recipient. Jest to zawsze adres Ethereum.

Ostrzeżenie: Sposób, w jaki uzyskujemy tutaj poświadczenia, ma dwa problemy z bezpieczeństwem.

  • Korzystamy z punktu końcowego API https://optimism.easscan.org/graphql, który jest scentralizowanym komponentem. Możemy pobrać atrybut id, a następnie sprawdzić on-chain, aby zweryfikować, czy poświadczenie jest prawdziwe, ale punkt końcowy API nadal może cenzurować poświadczenia, nie informując nas o nich.

    Ten problem nie jest niemożliwy do rozwiązania, moglibyśmy uruchomić własny punkt końcowy GraphQL i pobierać poświadczenia z logów łańcucha, ale jest to nadmierne dla naszych celów.

  • Nie sprawdzamy tożsamości poświadczającego. Każdy może podać nam fałszywe informacje. W rzeczywistej implementacji mielibyśmy zestaw zaufanych poświadczających i sprawdzalibyśmy tylko ich poświadczenia.

Aby zobaczyć to w działaniu, zatrzymaj istniejące IdP i SP i uruchom te polecenia:

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

Następnie podaj swój adres e-mail. Masz na to dwa sposoby:

  • Zaimportuj portfel za pomocą klucza prywatnego i użyj testowego klucza prywatnego 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

  • Dodaj poświadczenie dla własnego adresu e-mail:

    1. Przejdź do schematu w eksploratorze poświadczeń (opens in a new tab).

    2. Kliknij Poświadcz za pomocą schematu.

    3. Wprowadź swój adres Ethereum jako odbiorcę, swój adres e-mail jako adres e-mail i wybierz Onchain. Następnie kliknij Utwórz poświadczenie.

    4. Zatwierdź transakcję w swoim portfelu. Będziesz potrzebować trochę ETH w łańcuchu bloków Optimism (opens in a new tab), aby zapłacić za gaz.

Tak czy inaczej, po wykonaniu tej czynności przejdź do http://localhost:3000 (opens in a new tab) i postępuj zgodnie z instrukcjami. Jeśli zaimportowałeś testowy klucz prywatny, otrzymany adres e-mail to test_addr_0@example.com. Jeśli użyłeś własnego adresu, powinien to być adres, który poświadczyłeś.

Szczegółowe wyjaśnienie

Przejście od adresu Ethereum do adresu e-mail

Nowe kroki to komunikacja GraphQL, kroki 5.6 i 5.7.

Ponownie, oto zmienione części idp.mts.

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

Zaimportuj biblioteki, których potrzebujemy.

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

Dla każdego łańcucha bloków istnieje oddzielny punkt końcowy (opens in a new tab).

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

Utwórz nowego klienta GraphQLClient, którego możemy użyć do odpytywania punktu końcowego.

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

GraphQL daje nam tylko nieprzezroczysty obiekt danych z bajtami. Aby go zrozumieć, potrzebujemy schematu.

1const ethereumAddressToEmail = async ethAddr => {

Funkcja do konwersji adresu Ethereum na adres e-mail.

1 const query = `
2 query GetAttestationsByRecipient {

To jest zapytanie GraphQL.

1 poświadczenia(

Szukamy poświadczeń.

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

Poświadczenia, których chcemy, to te w naszym schemacie, gdzie odbiorcą jest getAddress(ethAddr). Funkcja getAddress (opens in a new tab) upewnia się, że nasz adres ma poprawną sumę kontrolną (opens in a new tab). Jest to konieczne, ponieważ w GraphQL wielkość liter ma znaczenie. "0xBAD060A7", "0xBad060A7" i "0xbad060a7" to różne wartości.

1 take: 1

Niezależnie od tego, ile poświadczeń znajdziemy, chcemy tylko pierwsze.

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

Pola, które chcemy otrzymać.

  • attester: Adres, który przesłał poświadczenie. Zwykle służy to do decydowania, czy ufać poświadczeniu, czy nie.
  • id: ID poświadczenia. Możesz użyć tej wartości, aby odczytać poświadczenie on-chain (opens in a new tab), aby zweryfikować, czy informacje z zapytania GraphQL są poprawne.
  • data: Dane schematu (w tym przypadku adres e-mail).
1 const queryResult = await graphqlClient.request(query)
2
3 if (queryResult.attestations.length == 0)
4 return "no_address@available.is"

Jeśli nie ma poświadczenia, zwróć wartość, która jest oczywiście nieprawidłowa, ale która wydawałaby się ważna dla dostawcy usług.

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

Jeśli istnieje wartość, użyj decodeData do zdekodowania danych. Nie potrzebujemy metadanych, które dostarcza, tylko samej wartości.

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 );
Pokaż wszystko

Użyj nowej funkcji, aby uzyskać adres e-mail.

A co z decentralizacją?

W tej konfiguracji użytkownicy nie mogą udawać kogoś, kim nie są, o ile polegamy na godnych zaufania poświadczających w mapowaniu adresów Ethereum na adresy e-mail. Jednak nasz dostawca tożsamości jest nadal scentralizowanym komponentem. Każdy, kto ma klucz prywatny dostawcy tożsamości, może wysyłać fałszywe informacje do dostawcy usług.

Może istnieć rozwiązanie wykorzystujące obliczenia wielostronne (MPC) (opens in a new tab). Mam nadzieję napisać o tym w przyszłym samouczku.

Podsumowanie

Przyjęcie standardu logowania, takiego jak podpisy Ethereum, stoi przed problemem jajka i kury. Dostawcy usług chcą dotrzeć do jak najszerszego rynku. Użytkownicy chcą mieć dostęp do usług bez martwienia się o obsługę ich standardu logowania. Tworzenie adapterów, takich jak IdP Ethereum, może pomóc nam pokonać tę przeszkodę.

Zobacz więcej mojej pracy tutaj (opens in a new tab).

Strona ostatnio zaktualizowana: 23 listopada 2025

Czy ten samouczek był pomocny?