Używanie Ethereum do uwierzytelniania web2
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óż.
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.
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.
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.
-
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-only1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only2cd 250420-saml-ethereum3pnpm install -
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 keys2cd keys3openssl 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 .. -
Uruchom serwery (zarówno SP, jak i IdP)
1pnpm start -
Przejdź do SP pod adresem URL http://localhost:3000/ (opens in a new tab) i kliknij przycisk, aby zostać przekierowanym do IdP (port 3001).
-
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:
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")23const 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 = 30002export const spHostname = "localhost"3export const spDir = "sp"45export const idpPort = 30016export const idpHostname = "localhost"7export const idpDir = "idp"89export const spUrl = `${protocol}://${spHostname}:${spPort}/${spDir}`10export const idpUrl = `${protocol}://${idpHostname}:${idpPort}/${idpDir}`Pokaż wszystkoAdresy 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ż wszystkoNajważ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ż wszystkoDane 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")).default2const 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()23const sp = saml.ServiceProvider({4 privateKey: spPrivateKey, 5 ...config.spPublicData6})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)78app.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ż wszystkoJeś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 atrybuty4 attributeNamePrefix: "@_", // Prefiks dla atrybutów5 }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ług13 </button>Pokaż wszystkoSą dwa pola, które wysyłamy do dostawcy usług:
requestId, na który odpowiadamy.- Identyfikator użytkownika (na razie używamy adresu e-mail podanego przez użytkownika).
1 </form>2 </body>3</html>45const idpRouter = express.Router()67idpRouter.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.requestId4 }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 podpisywaniePotrzebujemy signingKey, aby mieć dane do podpisania odpowiedzi. Dostawca usług nie ufa niepodpisanym żądaniom.
1 },2 "post",3 {4 email: req.body.emailTo jest pole z informacjami o użytkowniku, które odsyłamy do dostawcy usług.
1 }2 );34 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ż wszystkoPonownie, użyj automatycznie przesyłanego formularza. To jest krok 6 na powyższym diagramie sekwencji.
12// Punkt końcowy IdP dla żądań logowania3idpRouter.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-signatures2pnpm install3pnpm startNastę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.
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 requestID2let 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] = requestIdUtwó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 + "/" + signature3 window.location.href = path4 })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 = path6 }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) podpis7 </button>8 <br/>9 <button onClick="window.badSignature()">10 Prześlij zły (nieważny) podpis11 </button>12 </body>13</html> 14`15}Pokaż wszystkoReszta 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] = undefinedPobierz 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.signature5 })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ń logowania2idpRouter.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ż wszystkoZamiast 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: 18 ) { 9 data10 id11 attester12 }13}Pokaż wszystkoTen 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ć atrybutid, 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-address2pnpm install3pnpm startNastę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:
-
Przejdź do schematu w eksploratorze poświadczeń (opens in a new tab).
-
Kliknij Poświadcz za pomocą schematu.
-
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.
-
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
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: 1Niezależnie od tego, ile poświadczeń znajdziemy, chcemy tylko pierwsze.
1 ) {2 data3 id4 attester5 }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)23 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.value3}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ż wszystkoUż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





