Zum Hauptinhalt springen

Ethereum für die Web2-Authentifizierung nutzen

web2
Authentifizierung
eas
Anfänger
Ori Pomerantz
30. April 2025
20 Minuten Lesezeit

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.

Schritt-für-Schritt-SAML-Prozess

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.

Ethereum-Anmeldung

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.

Zusätzliche Daten aus Bestätigungen erhalten

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.

  1. 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.git
    2cd 250420-saml-ethereum
    3git checkout saml-only
    4yarn
  2. 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 keys
    2openssl 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"
  3. Starten Sie die Server (sowohl SP als auch IdP).

    1yarn start
  4. 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.

  5. 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:

Normale SAML-Anmeldung ohne Ethereum

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"
2

Da wir vorerst nur testen, ist es in Ordnung, HTTP zu verwenden.

1const protocol = "http"
2const domain = "localhost"
3

Lesen 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")
3

Die URLs für beide Komponenten.

1export const spPort = 3000
2export const spUrl = `${protocol}://${domain}:${spPort}`
3
4export const idpPort = 3001
5export const idpUrl = `${protocol}://${domain}:${idpPort}`
6

Die ö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}
7

Die ö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 anzeigen

src/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"
2
3import { spPort, spUrl, spPublicData, idpPublicData } from "./config.mjs"
4
5samlify.setSchemaValidator(validator)
6

Ein 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()
2

Die 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})
5

Die öffentlichen Daten enthalten alles, was der Dienstanbieter über den Identitätsanbieter wissen muss.

1const idp = samlify.IdentityProvider(idpPublicData)
2

Um 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})
4

Dies 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 anzeigen

Informieren 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})
12
Alle anzeigen

Erstellen 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")
2

Diese 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})
9
10const app = express()
11
Alle anzeigen

Diese 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 }))
2

Binden Sie den Router im Verzeichnis des Dienstanbieters (/sp) ein.

1app.use("/sp", spRouter)
2

Wenn 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})
11
Alle anzeigen

Hö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"
2
3import { idpPort, idpUrl, spPublicData, idpPublicData } from "./config.mjs"
4
5samlify.setSchemaValidator(validator)
6
7const idpRouter = express.Router()
8
9const idp = samlify.IdentityProvider({
10 ...idpPublicData,
11 privateKey: fs.readFileSync("./keys/idp-key.pem"),
12})
13
14const sp = samlify.ServiceProvider(spPublicData)
15
16idpRouter.get("/metadata", (req, res) => {
17 res.header("Content-Type", "text/xml").send(idp.getMetadata())
18})
19
Alle anzeigen

Diese 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:

  1. Die requestId, auf die wir antworten.
  2. 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`
9
Alle anzeigen

Dies 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 )
5

Verwenden 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})
13
Alle anzeigen

Dies 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"]
5
6 res.send(getLoginPage(reqId))
7})
8
9const app = express()
10
11app.use(express.urlencoded({ extended: true }))
12
13app.use("/idp", idpRouter)
14
15app.listen(idpPort, () => {
16 console.log(`Identity Provider listening at ${idpUrl}`)
17})
Alle anzeigen

Verwendung 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-eth
2yarn
3yarn start

Rufen 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.

SAML mit einer Ethereum-Signatur

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"
3
4import { 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: "
2

Wir 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> = {}
2

Erstellen 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] = reqId
4
5 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'
3
4 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 return
4 }
5

Fordern 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]
3

Erstellen 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 })
5

Bitten 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 })
4

Leiten Sie zu /idp/signature/<nonce>/<address>/<signature> weiter. Dies ist Schritt 5 im Sequenzdiagramm.

1 window.location.href = '/idp/signature/${nonce}/' + account + '/' + signature
2 }
3
4 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}
7

Dies ist der Handler für Schritt 5 im Sequenzdiagramm.

1idpRouter.get("/signature/:nonce/:address/:signature", async (req, res) => {
2 const { nonce, address, signature } = req.params
3

Holen 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]
3
4 if (!reqId) {
5 res.status(400).send("Invalid nonce")
6 return
7 }
8

Da 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 })
6
7 if (!valid) {
8 res.status(400).send("Invalid signature")
9 return
10 }
11 } catch (e) {
12 res.status(400).send("Invalid signature")
13 return
14 }
15
Alle anzeigen

Der 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 anzeigen

Wir 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 )
4
5 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})
17
18idpRouter.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"]
23
Alle anzeigen

Verwenden 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 attester
4 id
5 data
6 }
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/graphql zu, der eine zentralisierte Komponente ist. Wir können das id-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-eas
2yarn
3yarn start

Geben 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:

    1. Rufen Sie das Schema im Attestation Explorer (opens in a new tab) auf.

    2. Klicken Sie auf Attest with Schema.

    3. 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.

    4. 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

Von der Ethereum-Adresse zur E-Mail gelangen

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"
3
4import { idpPort, idpUrl, spPublicData, idpPublicData } from "./config.mjs"
5

Es gibt einen separaten Endpunkt für jede Blockchain (opens in a new tab).

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

Erstellen Sie einen neuen GraphQLClient-Client, den wir zum Abfragen des Endpunkts verwenden können.

1const easClient = new GraphQLClient(easEndpoint)
2

GraphQL gibt uns nur ein undurchsichtiges Datenobjekt mit Bytes. Um es zu verstehen, benötigen wir das Schema.

1const schemaEncoder = new SchemaEncoder("string email")
2

Eine 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: 1

Unabhä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 attester
2 id
3 data
4 }
5 }
6 `
7
8 const variables = {
9 schemaId: {
10 equals:
11 "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977",
12 },
13 recipient: {
14 equals: getAddress(ethAddr),
15 },
16 }
17
18 const response: any = await easClient.request(query, variables)
19
Alle anzeigen

Wenn 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 }
4

Wenn 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.value
3}
4
5samlify.setSchemaValidator(validator)

Verwenden Sie die neue Funktion, um die E-Mail-Adresse zu erhalten.

1 const email = await getEmail(address)
2
3 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 anzeigen

Was 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

War dieses Tutorial hilfreich?