Ethereum für die Web2-Authentifizierung nutzen
Einführung
SAML (opens in a new tab) ist ein Standard, der im Web2 verwendet wird, um einem Identitätsanbieter (IdP) (opens in a new tab) zu ermöglichen, Benutzerinformationen für Dienstanbieter (SP) (opens in a new tab) bereitzustellen.
In diesem Tutorial lernen Sie, wie Sie Ethereum-Signaturen in SAML integrieren, damit Benutzer ihre Ethereum-Wallets verwenden können, um sich bei Web2-Diensten zu authentifizieren, die Ethereum noch nicht nativ unterstützen.
Beachten Sie, dass dieses Tutorial für zwei verschiedene Zielgruppen geschrieben wurde:
- Ethereum-Nutzer, die Ethereum verstehen und SAML lernen müssen
- Web2-Nutzer, die SAML und Web2-Authentifizierung verstehen und Ethereum lernen müssen
Daher wird es viel Einführungsmaterial enthalten, das Sie vielleicht schon kennen. Sie können dieses gerne überspringen.
SAML für Ethereum-Nutzer
SAML ist ein zentralisiertes Protokoll. Ein Dienstanbieter (SP) akzeptiert Zusicherungen (wie „Dies ist mein Benutzer John, er sollte die Berechtigungen haben, A, B und C zu tun“) von einem Identitätsanbieter (IdP) nur, wenn bereits eine Vertrauensbeziehung zu ihm oder zu der Zertifizierungsstelle (opens in a new tab) besteht, die das Zertifikat dieses IdP signiert hat.
Zum Beispiel kann der SP ein Reisebüro sein, das Reisedienstleistungen für Unternehmen anbietet, und der IdP kann die interne Website eines Unternehmens sein. Wenn Mitarbeiter Geschäftsreisen buchen müssen, leitet das Reisebüro sie zur Authentifizierung durch das Unternehmen weiter, bevor sie die Reise tatsächlich buchen können.
Auf diese Weise verhandeln die drei Entitäten – der Browser, der SP und der IdP – über den Zugriff. Der SP muss im Voraus nichts über den Benutzer wissen, der den Browser verwendet, sondern nur dem IdP vertrauen.
Ethereum für SAML-Nutzer
Ethereum ist ein dezentralisiertes System.
Benutzer haben einen Private-Key (der typischerweise in einer Browser-Erweiterung gespeichert ist). Aus dem Private-Key kann man einen Public-Key ableiten und daraus eine 20-Byte-Adresse. Wenn sich Benutzer an einem System anmelden müssen, werden sie aufgefordert, eine Nachricht mit einer Nonce (einem Einmalwert) zu signieren. Der Server kann verifizieren, dass die Signatur von dieser Adresse erstellt wurde.
Die Signatur verifiziert nur die Ethereum-Adresse. Um andere Benutzerattribute zu erhalten, verwendet man typischerweise Bestätigungen (opens in a new tab). Eine Bestätigung hat typischerweise diese Felder:
- Attestor (Bestätigender), die Adresse, die die Bestätigung vorgenommen hat
- Empfänger, die Adresse, auf die sich die Bestätigung bezieht
- Daten, die bestätigten Daten, wie Name, Berechtigungen usw.
- Schema, die ID des Schemas, das zur Interpretation der Daten verwendet wird.
Aufgrund der dezentralisierten Natur von Ethereum kann jeder Benutzer Bestätigungen vornehmen. Die Identität des Bestätigenden ist wichtig, um zu identifizieren, welche Bestätigungen wir als zuverlässig erachten.
Einrichtung
Der erste Schritt besteht darin, dass ein SAML-SP und ein SAML-IdP miteinander kommunizieren.
-
Laden Sie die Software herunter. Die Beispielsoftware für diesen Artikel befindet sich auf GitHub (opens in a new tab). Verschiedene Phasen sind in verschiedenen Branches gespeichert; für diese Phase benötigen Sie
saml-only.1git clone https://github.com/qbzzt/250420-saml-ethereum.git2cd 250420-saml-ethereum3git checkout saml-only4yarn -
Erstellen Sie Schlüssel mit selbstsignierten Zertifikaten. Das bedeutet, dass der Schlüssel seine eigene Zertifizierungsstelle ist und manuell in den Dienstanbieter importiert werden muss. Weitere Informationen finden Sie in den OpenSSL-Dokumentationen (opens in a new tab).
1mkdir keys2openssl req -x509 -newkey rsa:4096 -keyout keys/sp-key.pem -out keys/sp-cert.pem -sha256 -days 3650 -nodes -subj "/C=US/ST=Texas/L=Austin/O=Service Provider/OU=IT/CN=localhost"3openssl req -x509 -newkey rsa:4096 -keyout keys/idp-key.pem -out keys/idp-cert.pem -sha256 -days 3650 -nodes -subj "/C=US/ST=Texas/L=Austin/O=Identity Provider/OU=IT/CN=localhost" -
Starten Sie die Server (sowohl SP als auch IdP).
1yarn start -
Rufen Sie den SP unter der URL http://localhost:3000/ (opens in a new tab) auf und klicken Sie auf die Schaltfläche, um zum IdP (Port 3001) weitergeleitet zu werden.
-
Geben Sie dem IdP Ihre E-Mail-Adresse an und klicken Sie auf Login to the service provider. Sie werden sehen, dass Sie zurück zum Dienstanbieter (Port 3000) weitergeleitet werden und dieser Sie anhand Ihrer E-Mail-Adresse erkennt.
Detaillierte Erklärung
Das passiert Schritt für Schritt:
src/config.mts
Diese Datei enthält die Konfiguration sowohl für den Identitätsanbieter als auch für den Dienstanbieter. Normalerweise wären diese beiden unterschiedliche Entitäten, aber hier können wir den Code der Einfachheit halber teilen.
1import fs from "fs"2Da wir vorerst nur testen, ist es in Ordnung, HTTP zu verwenden.
1const protocol = "http"2const domain = "localhost"3Lesen Sie die Public-Keys, die normalerweise beiden Komponenten zur Verfügung stehen (und denen entweder direkt vertraut wird oder die von einer vertrauenswürdigen Zertifizierungsstelle signiert wurden).
1const spPubKey = fs.readFileSync("./keys/sp-cert.pem")2const idpPubKey = fs.readFileSync("./keys/idp-cert.pem")3Die URLs für beide Komponenten.
1export const spPort = 30002export const spUrl = `${protocol}://${domain}:${spPort}`34export const idpPort = 30015export const idpUrl = `${protocol}://${domain}:${idpPort}`6Die öffentlichen Daten für den Dienstanbieter.
1export const spPublicData = {Konventionsgemäß ist in SAML die entityID die URL, unter der die Metadaten der Entität verfügbar sind. Diese Metadaten entsprechen den hier aufgeführten öffentlichen Daten, außer dass sie im XML-Format vorliegen.
1 entityID: `${spUrl}/sp/metadata`,Die wichtigste Definition für unsere Zwecke ist der assertionConsumerServer. Das bedeutet, dass wir, um dem Dienstanbieter etwas zuzusichern (zum Beispiel „der Benutzer, der Ihnen diese Informationen sendet, ist somebody@example.com (opens email client)“), HTTP POST (opens in a new tab) an die URL http://localhost:3000/sp/assertion verwenden müssen.
1 assertionConsumerService: [{2 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",3 Location: `${spUrl}/sp/assertion`,4 }],5 encPrivateKey: spPubKey,6}7Die öffentlichen Daten für den Identitätsanbieter sind ähnlich. Sie geben an, dass Sie zum Anmelden eines Benutzers einen POST an http://localhost:3001/idp/login und zum Abmelden eines Benutzers einen POST an http://localhost:3001/idp/logout senden.
1export const idpPublicData = {2 entityID: `${idpUrl}/idp/metadata`,3 singleSignOnService: [{4 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",5 Location: `${idpUrl}/idp/login`,6 }],7 singleLogoutService: [{8 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",9 Location: `${idpUrl}/idp/logout`,10 }],11 signingCert: idpPubKey,12}Alle anzeigensrc/sp.mts
Dies ist der Code, der einen Dienstanbieter implementiert.
1import fs from "fs"2import express from "express"Wir verwenden die Bibliothek samlify (opens in a new tab), um SAML zu implementieren.
1import * as samlify from "samlify"Die Bibliothek samlify erwartet ein Paket, das validiert, ob das XML korrekt ist, mit dem erwarteten Public-Key signiert wurde usw. Wir verwenden dafür @authenio/samlify-node-xmllint (opens in a new tab).
1import * as validator from "@authenio/samlify-node-xmllint"23import { spPort, spUrl, spPublicData, idpPublicData } from "./config.mjs"45samlify.setSchemaValidator(validator)6Ein express (opens in a new tab) Router (opens in a new tab) ist eine „Mini-Website“, die in eine Website eingebunden werden kann. In diesem Fall verwenden wir ihn, um alle Definitionen des Dienstanbieters zu gruppieren.
1const spRouter = express.Router()2Die eigene Repräsentation des Dienstanbieters besteht aus all seinen öffentlichen Daten und dem Private-Key, den er zum Signieren von Informationen verwendet.
1const sp = samlify.ServiceProvider({2 ...spPublicData,3 privateKey: fs.readFileSync("./keys/sp-key.pem"),4})5Die öffentlichen Daten enthalten alles, was der Dienstanbieter über den Identitätsanbieter wissen muss.
1const idp = samlify.IdentityProvider(idpPublicData)2Um die Interoperabilität mit anderen SAML-Komponenten zu ermöglichen, sollten Dienst- und Identitätsanbieter ihre öffentlichen Daten (die sogenannten Metadaten) im XML-Format unter /metadata zur Verfügung stellen.
1spRouter.get("/metadata", (req, res) => {2 res.header("Content-Type", "text/xml").send(sp.getMetadata())3})4Dies ist die Seite, auf die der Browser zugreift, um sich zu identifizieren. Die Zusicherung enthält die Benutzerkennung (hier verwenden wir die E-Mail-Adresse) und kann zusätzliche Attribute enthalten. Dies ist der Handler für Schritt 7 im obigen Sequenzdiagramm.
1spRouter.post("/assertion", async (req, res) => {2 try {Sie können den auskommentierten Befehl verwenden, um die in der Zusicherung bereitgestellten XML-Daten anzuzeigen. Sie sind Base64-kodiert (opens in a new tab).
1 // console.log(Buffer.from(req.body.SAMLResponse, 'base64').toString('utf8'))Parsen Sie die Anmeldeanforderung vom Identitätsserver.
1 const { extract } = await sp.parseLoginResponse(idp, "post", req)Senden Sie eine HTML-Antwort, nur um dem Benutzer zu zeigen, dass wir die Anmeldung erhalten haben.
1 res.send(`2 <html>3 <body>4 <h1>Login Successful</h1>5 <p>Welcome, ${extract.nameID}</p>6 </body>7 </html>8 `)9 } catch (e) {Alle anzeigenInformieren Sie den Benutzer im Fehlerfall.
1 console.error("[FATAL] when parsing login response sent from idp", e)2 res.status(500).send(`3 <html>4 <body>5 <h1>Login Failed</h1>6 <p>Check the console for more information</p>7 </body>8 </html>9 `)10 }11})12Alle anzeigenErstellen Sie eine Anmeldeanforderung, wenn der Browser versucht, diese Seite abzurufen. Dies ist der Handler für Schritt 1 im obigen Sequenzdiagramm.
1spRouter.get("/login", async (req, res) => {Holen Sie sich die Informationen, um eine Anmeldeanforderung zu posten.
1 const loginRequest = sp.createLoginRequest(idp, "redirect")2Diese Seite sendet das Formular (siehe unten) automatisch ab. Auf diese Weise muss der Benutzer nichts tun, um weitergeleitet zu werden. Dies ist Schritt 2 im obigen Sequenzdiagramm.
1 const html = `2 <html>3 <body onload="document.forms[0].submit()">Posten Sie an loginRequest.entityEndpoint (die URL des Endpunkts des Identitätsanbieters).
1 <form method="post" action="${loginRequest.entityEndpoint}">Der Eingabename ist loginRequest.type (SAMLRequest). Der Inhalt für dieses Feld ist loginRequest.context, was wiederum Base64-kodiertes XML ist.
1 <input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}" />2 <input type="submit" value="Login" />3 </form>4 </body>5 </html>6 `7 res.send(html)8})910const app = express()11Alle anzeigenDiese Middleware (opens in a new tab) liest den Body der HTTP-Anfrage (opens in a new tab). Standardmäßig ignoriert Express ihn, da die meisten Anfragen ihn nicht benötigen. Wir brauchen ihn, weil POST den Body verwendet.
1app.use(express.urlencoded({ extended: true }))2Binden Sie den Router im Verzeichnis des Dienstanbieters (/sp) ein.
1app.use("/sp", spRouter)2Wenn ein Browser versucht, das Stammverzeichnis abzurufen, stellen Sie ihm einen Link zur Anmeldeseite zur Verfügung.
1app.get("/", (req, res) => {2 res.send(`3 <html>4 <body>5 <h1>Service Provider</h1>6 <a href="/sp/login">Login</a>7 </body>8 </html>9 `)10})11Alle anzeigenHören Sie mit dieser Express-Anwendung auf den spPort.
1app.listen(spPort, () => {2 console.log(`Service Provider listening at ${spUrl}`)3})src/idp.mts
Dies ist der Identitätsanbieter. Er ist dem Dienstanbieter sehr ähnlich; die folgenden Erklärungen beziehen sich auf die Teile, die unterschiedlich sind.
1import fs from "fs"2import express from "express"3import * as samlify from "samlify"4import * as validator from "@authenio/samlify-node-xmllint"Wir müssen die XML-Anfrage, die wir vom Dienstanbieter erhalten, lesen und verstehen.
1import { XMLParser } from "fast-xml-parser"23import { idpPort, idpUrl, spPublicData, idpPublicData } from "./config.mjs"45samlify.setSchemaValidator(validator)67const idpRouter = express.Router()89const idp = samlify.IdentityProvider({10 ...idpPublicData,11 privateKey: fs.readFileSync("./keys/idp-key.pem"),12})1314const sp = samlify.ServiceProvider(spPublicData)1516idpRouter.get("/metadata", (req, res) => {17 res.header("Content-Type", "text/xml").send(idp.getMetadata())18})19Alle anzeigenDiese Funktion erstellt die Seite mit dem automatisch abgesendeten Formular, das in Schritt 4 des obigen Sequenzdiagramms zurückgegeben wird.
1const getLoginPage = (reqId: string) => `2 <html>3 <body>4 <h1>Identity Provider</h1>5 <form method="post" action="/idp/loginSubmitted">Es gibt zwei Felder, die wir an den Dienstanbieter senden:
- Die
requestId, auf die wir antworten. - Die Benutzerkennung (wir verwenden vorerst die vom Benutzer angegebene E-Mail-Adresse).
1 <input type="hidden" name="reqId" value="${reqId}" />2 <label for="email">Email:</label>3 <input type="email" name="email" id="email" />4 <input type="submit" value="Login to the service provider" />5 </form>6 </body>7 </html>8`9Alle anzeigenDies ist der Handler für Schritt 5 des obigen Sequenzdiagramms. idp.createLoginResponse (opens in a new tab) erstellt die Anmeldeantwort.
1idpRouter.post("/loginSubmitted", async (req, res) => {2 const { context } = await idp.createLoginResponse(Die Zielgruppe (Audience) ist der Dienstanbieter.
1 sp,Aus der Anfrage extrahierte Informationen. Der einzige Parameter, der uns in der Anfrage interessiert, ist die requestId, die es dem Dienstanbieter ermöglicht, Anfragen und deren Antworten zuzuordnen.
1 {2 extract: {3 request: {4 id: req.body.reqId,5 },6 },7 },8 "post",Wir benötigen den signingKey, um die Daten zum Signieren der Antwort zu haben. Der Dienstanbieter vertraut keinen unsignierten Anfragen.
1 {2 signingKey: fs.readFileSync("./keys/idp-key.pem"),3 },Dies ist das Feld mit den Benutzerinformationen, die wir an den Dienstanbieter zurücksenden.
1 (template: string) => {2 return template.replace("{nameID}", req.body.email)3 }4 )5Verwenden Sie erneut ein automatisch abgesendetes Formular. Dies ist Schritt 6 des obigen Sequenzdiagramms.
1 const html = `2 <html>3 <body onload="document.forms[0].submit()">4 <form method="post" action="${spPublicData.assertionConsumerService[0].Location}">5 <input type="hidden" name="SAMLResponse" value="${context}" />6 <input type="submit" value="Login" />7 </form>8 </body>9 </html>10 `11 res.send(html)12})13Alle anzeigenDies ist der Endpunkt, der eine Anmeldeanforderung vom Dienstanbieter empfängt. Dies ist der Handler für Schritt 3 des obigen Sequenzdiagramms.
1idpRouter.post("/login", async (req, res) => {Wir sollten in der Lage sein, idp.parseLoginRequest (opens in a new tab) zu verwenden, um die ID der Authentifizierungsanfrage zu lesen. Ich konnte es jedoch nicht zum Laufen bringen und es war nicht wert, viel Zeit darauf zu verwenden, also verwende ich einfach einen allgemeinen XML-Parser (opens in a new tab). Die Information, die wir benötigen, ist das ID-Attribut innerhalb des <samlp:AuthnRequest>-Tags, das sich auf der obersten Ebene des XML befindet.
1 const xmlStr = Buffer.from(req.body.SAMLRequest, "base64").toString("utf8")2 const parser = new XMLParser({ ignoreAttributes: false })3 const xmlObj = parser.parse(xmlStr)4 const reqId = xmlObj["samlp:AuthnRequest"]["@_ID"]56 res.send(getLoginPage(reqId))7})89const app = express()1011app.use(express.urlencoded({ extended: true }))1213app.use("/idp", idpRouter)1415app.listen(idpPort, () => {16 console.log(`Identity Provider listening at ${idpUrl}`)17})Alle anzeigenVerwendung von Ethereum-Signaturen
Da wir nun eine Benutzeridentität an den Dienstanbieter senden können, besteht der nächste Schritt darin, die Benutzeridentität auf vertrauenswürdige Weise zu erhalten. Viem ermöglicht es uns, einfach das Wallet nach der Benutzeradresse zu fragen, aber das bedeutet, den Browser nach den Informationen zu fragen. Wir kontrollieren den Browser nicht, also können wir der Antwort, die wir von ihm erhalten, nicht automatisch vertrauen.
Stattdessen wird der IdP dem Browser eine Zeichenfolge zum Signieren senden. Wenn das Wallet im Browser diese Zeichenfolge signiert, bedeutet das, dass es sich wirklich um diese Adresse handelt (das heißt, es kennt den Private-Key, der der Adresse entspricht).
Um dies in Aktion zu sehen, stoppen Sie den bestehenden IdP und SP und führen Sie diese Befehle aus:
1git checkout saml-and-eth2yarn3yarn startRufen Sie dann den SP (opens in a new tab) auf und folgen Sie den Anweisungen.
Beachten Sie, dass wir zu diesem Zeitpunkt nicht wissen, wie wir die E-Mail-Adresse aus der Ethereum-Adresse erhalten, also melden wir stattdessen <ethereum address>@bad.email.address an den SP.
Detaillierte Erklärung
Die Änderungen befinden sich in den Schritten 4-5 im vorherigen Diagramm.
Die einzige Datei, die wir geändert haben, ist idp.mts. Hier sind die geänderten Teile.
1import { XMLParser } from "fast-xml-parser"Wir benötigen diese beiden zusätzlichen Bibliotheken. Wir verwenden uuid (opens in a new tab), um den Nonce (opens in a new tab)-Wert zu erstellen. Der Wert selbst spielt keine Rolle, nur die Tatsache, dass er nur einmal verwendet wird.
Die Bibliothek viem (opens in a new tab) ermöglicht es uns, Ethereum-Definitionen zu verwenden. Hier benötigen wir sie, um zu verifizieren, dass die Signatur tatsächlich gültig ist.
1import { v4 as uuidv4 } from "uuid"2import { verifyMessage } from "viem"34import { idpPort, idpUrl, spPublicData, idpPublicData } from "./config.mjs"Das Wallet bittet den Benutzer um Erlaubnis, die Nachricht zu signieren. Eine Nachricht, die nur eine Nonce ist, könnte Benutzer verwirren, daher fügen wir diese Aufforderung hinzu.
1const prompt = "Please sign this message to log in. Nonce: "2Wir benötigen die Anfrageinformationen, um darauf antworten zu können. Wir könnten sie mit der Anfrage senden (Schritt 4) und sie zurückerhalten (Schritt 5). Wir können jedoch den Informationen, die wir vom Browser erhalten, nicht vertrauen, da dieser unter der Kontrolle eines potenziell feindlichen Benutzers steht. Es ist also besser, sie hier mit der Nonce als Schlüssel zu speichern.
Beachten Sie, dass wir dies hier der Einfachheit halber als Variable tun. Dies hat jedoch mehrere Nachteile:
- Wir sind anfällig für einen Denial-of-Service-Angriff. Ein böswilliger Benutzer könnte versuchen, sich mehrmals anzumelden und so unseren Speicher zu füllen.
- Wenn der IdP-Prozess neu gestartet werden muss, verlieren wir die vorhandenen Werte.
- Wir können keinen Lastausgleich über mehrere Prozesse hinweg durchführen, da jeder seine eigene Variable hätte.
Auf einem Produktionssystem würden wir eine Datenbank verwenden und eine Art Ablaufmechanismus implementieren.
1const nonces: Record<string, string> = {}2Erstellen Sie eine Nonce und speichern Sie die requestId für die zukünftige Verwendung.
1const getSignaturePage = (reqId: string) => {2 const nonce = uuidv4()3 nonces[nonce] = reqId45 return `6 <html>7 <head>Dieses JavaScript wird automatisch ausgeführt, wenn die Seite geladen wird.
1 <script type="module">Wir benötigen mehrere Funktionen von viem.
1 import { createWalletClient, custom } from 'https://esm.sh/viem'2 import 'https://esm.sh/viem/window'34 async function sign() {Wir können nur arbeiten, wenn sich ein Wallet im Browser befindet.
1 if (!window.ethereum) {2 alert('No wallet found')3 return4 }5Fordern Sie die Liste der Konten vom Wallet an (window.ethereum). Gehen Sie davon aus, dass es mindestens eines gibt, und speichern Sie nur das erste.
1 const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' })2 const account = accounts[0]3Erstellen Sie einen Wallet-Client (opens in a new tab), um mit dem Browser-Wallet zu interagieren.
1 const client = createWalletClient({2 account,3 transport: custom(window.ethereum)4 })5Bitten Sie den Benutzer, eine Nachricht zu signieren. Da sich dieses gesamte HTML in einem Template-String (opens in a new tab) befindet, können wir Variablen verwenden, die im IdP-Prozess definiert sind. Dies ist Schritt 4.5 im Sequenzdiagramm.
1 const signature = await client.signMessage({2 message: '${prompt}${nonce}'3 })4Leiten Sie zu /idp/signature/<nonce>/<address>/<signature> weiter. Dies ist Schritt 5 im Sequenzdiagramm.
1 window.location.href = '/idp/signature/${nonce}/' + account + '/' + signature2 }34 sign()5 </script>6 </head>Die Signatur wird vom Browser zurückgesendet, der potenziell böswillig ist (es hindert Sie nichts daran, einfach http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature im Browser zu öffnen). Daher ist es wichtig zu verifizieren, dass der IdP-Prozess fehlerhafte Signaturen korrekt verarbeitet.
1 <body>Der Rest ist nur Standard-HTML.
1 <h1>Identity Provider</h1>2 <p>Please sign the message in your wallet to log in.</p>3 </body>4 </html>5`6}7Dies ist der Handler für Schritt 5 im Sequenzdiagramm.
1idpRouter.get("/signature/:nonce/:address/:signature", async (req, res) => {2 const { nonce, address, signature } = req.params3Holen Sie sich die Anfrage-ID und löschen Sie die Nonce aus nonces, um sicherzustellen, dass sie nicht wiederverwendet werden kann.
1 const reqId = nonces[nonce]2 delete nonces[nonce]34 if (!reqId) {5 res.status(400).send("Invalid nonce")6 return7 }8Da es so viele Möglichkeiten gibt, wie die Signatur ungültig sein kann, verpacken wir dies in einen try ... catch-Block, um alle ausgelösten Fehler abzufangen.
1 try {Verwenden Sie verifyMessage (opens in a new tab), um Schritt 5.5 im Sequenzdiagramm zu implementieren.
1 const valid = await verifyMessage({2 address: address as `0x${string}`,3 message: `${prompt}${nonce}`,4 signature: signature as `0x${string}`,5 })67 if (!valid) {8 res.status(400).send("Invalid signature")9 return10 }11 } catch (e) {12 res.status(400).send("Invalid signature")13 return14 }15Alle anzeigenDer Rest des Handlers entspricht dem, was wir zuvor im /loginSubmitted-Handler getan haben, bis auf eine kleine Änderung.
1 const { context } = await idp.createLoginResponse(2 sp,3 {4 extract: {5 request: {6 id: reqId,7 },8 },9 },10 "post",11 {12 signingKey: fs.readFileSync("./keys/idp-key.pem"),13 },14 (template: string) => {Alle anzeigenWir haben nicht die tatsächliche E-Mail-Adresse (wir werden sie im nächsten Abschnitt erhalten), also geben wir vorerst die Ethereum-Adresse zurück und markieren sie deutlich als keine E-Mail-Adresse.
1 return template.replace("{nameID}", `${address}@bad.email.address`)2 }3 )45 const html = `6 <html>7 <body onload="document.forms[0].submit()">8 <form method="post" action="${spPublicData.assertionConsumerService[0].Location}">9 <input type="hidden" name="SAMLResponse" value="${context}" />10 <input type="submit" value="Login" />11 </form>12 </body>13 </html>14 `15 res.send(html)16})1718idpRouter.post("/login", async (req, res) => {19 const xmlStr = Buffer.from(req.body.SAMLRequest, "base64").toString("utf8")20 const parser = new XMLParser({ ignoreAttributes: false })21 const xmlObj = parser.parse(xmlStr)22 const reqId = xmlObj["samlp:AuthnRequest"]["@_ID"]23Alle anzeigenVerwenden Sie nun anstelle von getLoginPage getSignaturePage im Handler für Schritt 3.
1 res.send(getSignaturePage(reqId))2})Abrufen der E-Mail-Adresse
Der nächste Schritt besteht darin, die E-Mail-Adresse zu erhalten, die vom Dienstanbieter angeforderte Kennung. Dazu verwenden wir den Ethereum Attestation Service (EAS) (opens in a new tab).
Der einfachste Weg, Bestätigungen zu erhalten, ist die Verwendung der GraphQL-API (opens in a new tab). Wir verwenden diese Abfrage:
1query Attestations($schemaId: StringFilter!, $recipient: StringFilter!) {2 attestations(where: {schemaId: $schemaId, recipient: $recipient}) {3 attester4 id5 data6 }7}Diese schemaId (opens in a new tab) enthält nur eine E-Mail-Adresse. Diese Abfrage fragt nach Bestätigungen dieses Schemas. Das Subjekt der Bestätigung wird als recipient (Empfänger) bezeichnet. Es ist immer eine Ethereum-Adresse.
Warnung: Die Art und Weise, wie wir hier Bestätigungen erhalten, hat zwei Sicherheitsprobleme.
-
Wir greifen auf den API-Endpunkt
https://optimism.easscan.org/graphqlzu, der eine zentralisierte Komponente ist. Wir können dasid-Attribut abrufen und dann eine Suche auf der Blockchain durchführen, um zu verifizieren, dass eine Bestätigung echt ist, aber der API-Endpunkt kann Bestätigungen immer noch zensieren, indem er uns nichts davon erzählt.Dieses Problem ist nicht unlösbar; wir könnten unseren eigenen GraphQL-Endpunkt betreiben und die Bestätigungen aus den Chain-Logs abrufen, aber das ist für unsere Zwecke übertrieben.
-
Wir betrachten nicht die Identität des Bestätigenden. Jeder kann uns falsche Informationen liefern. In einer realen Implementierung hätten wir eine Reihe von vertrauenswürdigen Bestätigenden und würden nur deren Bestätigungen berücksichtigen.
Um dies in Aktion zu sehen, stoppen Sie den bestehenden IdP und SP und führen Sie diese Befehle aus:
1git checkout saml-eth-eas2yarn3yarn startGeben Sie dann Ihre E-Mail-Adresse an. Sie haben zwei Möglichkeiten, dies zu tun:
-
Importieren Sie ein Wallet mit einem Private-Key und verwenden Sie den Test-Private-Key
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80. -
Fügen Sie eine Bestätigung für Ihre eigene E-Mail-Adresse hinzu:
-
Rufen Sie das Schema im Attestation Explorer (opens in a new tab) auf.
-
Klicken Sie auf Attest with Schema.
-
Geben Sie Ihre Ethereum-Adresse als Empfänger und Ihre E-Mail-Adresse als E-Mail-Adresse ein und wählen Sie Onchain. Klicken Sie dann auf Make Attestation.
-
Genehmigen Sie die Transaktion in Ihrem Wallet. Sie benötigen etwas ETH auf der Optimism-Blockchain (opens in a new tab), um für Gas zu bezahlen.
-
So oder so, rufen Sie danach http://localhost:3000 (opens in a new tab) auf und folgen Sie den Anweisungen. Wenn Sie den Test-Private-Key importiert haben, lautet die E-Mail, die Sie erhalten, test_addr_0@example.com. Wenn Sie Ihre eigene Adresse verwendet haben, sollte es das sein, was Sie bestätigt haben.
Detaillierte Erklärung
Die neuen Schritte sind die GraphQL-Kommunikation, Schritte 5.6 und 5.7.
Hier sind wiederum die geänderten Teile von idp.mts.
1import { v4 as uuidv4 } from "uuid"2import { verifyMessage, getAddress } from "viem"Importieren Sie die Bibliotheken, die wir benötigen.
1import { GraphQLClient, gql } from "graphql-request"2import { SchemaEncoder } from "@ethereum-attestation-service/eas-sdk"34import { idpPort, idpUrl, spPublicData, idpPublicData } from "./config.mjs"5Es gibt einen separaten Endpunkt für jede Blockchain (opens in a new tab).
1const easEndpoint = "https://optimism.easscan.org/graphql"2Erstellen Sie einen neuen GraphQLClient-Client, den wir zum Abfragen des Endpunkts verwenden können.
1const easClient = new GraphQLClient(easEndpoint)2GraphQL gibt uns nur ein undurchsichtiges Datenobjekt mit Bytes. Um es zu verstehen, benötigen wir das Schema.
1const schemaEncoder = new SchemaEncoder("string email")2Eine Funktion, um von einer Ethereum-Adresse zu einer E-Mail-Adresse zu gelangen.
1const getEmail = async (ethAddr: string) => {Dies ist eine GraphQL-Abfrage.
1 const query = gql`Wir suchen nach Bestätigungen.
1 query Attestations($schemaId: StringFilter!, $recipient: StringFilter!) {2 attestations(3 where: { schemaId: $schemaId, recipient: $recipient }Die Bestätigungen, die wir wollen, sind diejenigen in unserem Schema, bei denen der Empfänger getAddress(ethAddr) ist. Die Funktion getAddress (opens in a new tab) stellt sicher, dass unsere Adresse die korrekte Prüfsumme (opens in a new tab) hat. Dies ist notwendig, da GraphQL zwischen Groß- und Kleinschreibung unterscheidet. „0xBAD060A7“, „0xBad060A7“ und „0xbad060a7“ sind unterschiedliche Werte.
1 take: 1Unabhängig davon, wie viele Bestätigungen wir finden, wollen wir nur die erste.
1 ) {Die Felder, die wir empfangen möchten.
attester: Die Adresse, die die Bestätigung eingereicht hat. Normalerweise wird dies verwendet, um zu entscheiden, ob der Bestätigung vertraut werden soll oder nicht.id: Die Bestätigungs-ID. Sie können diesen Wert verwenden, um die Bestätigung auf der Blockchain zu lesen (opens in a new tab), um zu verifizieren, dass die Informationen aus der GraphQL-Abfrage korrekt sind.data: Die Schemadaten (in diesem Fall die E-Mail-Adresse).
1 attester2 id3 data4 }5 }6 `78 const variables = {9 schemaId: {10 equals:11 "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977",12 },13 recipient: {14 equals: getAddress(ethAddr),15 },16 }1718 const response: any = await easClient.request(query, variables)19Alle anzeigenWenn es keine Bestätigung gibt, geben Sie einen Wert zurück, der offensichtlich falsch ist, aber dem Dienstanbieter als gültig erscheinen würde.
1 if (response.attestations.length === 0) {2 return `${ethAddr}@no.attestation.found`3 }4Wenn ein Wert vorhanden ist, verwenden Sie decodeData, um die Daten zu dekodieren. Wir benötigen nicht die bereitgestellten Metadaten, sondern nur den Wert selbst.
1 const data = schemaEncoder.decodeData(response.attestations[0].data)2 return data[0].value.value3}45samlify.setSchemaValidator(validator)Verwenden Sie die neue Funktion, um die E-Mail-Adresse zu erhalten.
1 const email = await getEmail(address)23 const { context } = await idp.createLoginResponse(4 sp,5 {6 extract: {7 request: {8 id: reqId,9 },10 },11 },12 "post",13 {14 signingKey: fs.readFileSync("./keys/idp-key.pem"),15 },16 (template: string) => {17 return template.replace("{nameID}", email)18 }19 )Alle anzeigenWas ist mit Dezentralisierung?
In dieser Konfiguration können Benutzer nicht vorgeben, jemand zu sein, der sie nicht sind, solange wir uns auf vertrauenswürdige Bestätigende für die Zuordnung von Ethereum- zu E-Mail-Adressen verlassen. Unser Identitätsanbieter ist jedoch immer noch eine zentralisierte Komponente. Wer auch immer den Private-Key des Identitätsanbieters besitzt, kann falsche Informationen an den Dienstanbieter senden.
Es könnte eine Lösung geben, die Multi-Party Computation (MPC) (opens in a new tab) verwendet. Ich hoffe, in einem zukünftigen Tutorial darüber zu schreiben.
Fazit
Die Einführung eines Anmeldestandards, wie z. B. Ethereum-Signaturen, steht vor einem Henne-Ei-Problem. Dienstanbieter möchten einen möglichst breiten Markt ansprechen. Benutzer möchten auf Dienste zugreifen können, ohne sich Gedanken über die Unterstützung ihres Anmeldestandards machen zu müssen. Die Erstellung von Adaptern, wie z. B. einem Ethereum-IdP, kann uns helfen, diese Hürde zu überwinden.
Weitere meiner Arbeiten finden Sie hier (opens in a new tab).
Letzte Aktualisierung der Seite: 23. November 2025





