Lompat ke konten utama

Menggunakan Ethereum untuk autentikasi web2

web2
autentikasi
eas
Pemula
Ori Pomerantz
30 April 2025
19 menit baca

Pengantar

SAML (opens in a new tab) adalah standar yang digunakan di web2 untuk memungkinkan penyedia identitas (IdP) (opens in a new tab) menyediakan informasi pengguna untuk penyedia layanan (SP) (opens in a new tab).

Dalam tutorial ini Anda akan mempelajari cara mengintegrasikan tanda tangan Ethereum dengan SAML untuk memungkinkan pengguna menggunakan dompet Ethereum mereka untuk mengautentikasi diri mereka sendiri ke layanan web2 yang belum mendukung Ethereum secara bawaan.

Perhatikan bahwa tutorial ini ditulis untuk dua audiens yang berbeda:

  • Orang-orang Ethereum yang memahami Ethereum dan perlu mempelajari SAML
  • Orang-orang Web2 yang memahami SAML dan autentikasi web2 dan perlu mempelajari Ethereum

Akibatnya, tutorial ini akan berisi banyak materi pengantar yang mungkin sudah Anda ketahui. Jangan ragu untuk melewatinya.

SAML untuk orang-orang Ethereum

SAML adalah protokol terpusat. Penyedia layanan (SP) hanya menerima asersi (seperti "ini adalah pengguna saya John, dia harus memiliki izin untuk melakukan A, B, dan C") dari penyedia identitas (IdP) jika ia memiliki hubungan kepercayaan yang sudah ada sebelumnya baik dengannya, atau dengan otoritas sertifikat (opens in a new tab) yang menandatangani sertifikat IdP tersebut.

Sebagai contoh, SP dapat berupa agen perjalanan yang menyediakan layanan perjalanan untuk perusahaan, dan IdP dapat berupa situs web internal perusahaan. Ketika karyawan perlu memesan perjalanan bisnis, agen perjalanan mengirim mereka untuk autentikasi oleh perusahaan sebelum membiarkan mereka benar-benar memesan perjalanan.

Proses SAML langkah demi langkah

Ini adalah cara ketiga entitas, yaitu peramban, SP, dan IdP, bernegosiasi untuk mendapatkan akses. SP tidak perlu mengetahui apa pun tentang pengguna yang menggunakan peramban sebelumnya, hanya perlu memercayai IdP.

Ethereum untuk orang-orang SAML

Ethereum adalah sistem desentralisasi.

Logon Ethereum

Pengguna memiliki kunci pribadi (biasanya disimpan dalam ekstensi peramban). Dari kunci pribadi Anda dapat memperoleh kunci publik, dan dari situ sebuah alamat 20-byte. Ketika pengguna perlu masuk ke dalam sistem, mereka diminta untuk menandatangani pesan dengan nonce (nilai sekali pakai). Server dapat memverifikasi bahwa tanda tangan tersebut dibuat oleh alamat tersebut.

Mendapatkan data tambahan dari pengesahan

Tanda tangan hanya memverifikasi alamat Ethereum. Untuk mendapatkan atribut pengguna lainnya, Anda biasanya menggunakan pengesahan (opens in a new tab). Sebuah pengesahan biasanya memiliki bidang-bidang berikut:

  • Attestor, alamat yang membuat pengesahan
  • Penerima, alamat yang menjadi tujuan pengesahan
  • Data, data yang disahkan, seperti nama, izin, dll.
  • Skema, ID dari skema yang digunakan untuk menafsirkan data.

Karena sifat desentralisasi dari Ethereum, setiap pengguna dapat membuat pengesahan. Identitas attestor penting untuk mengidentifikasi pengesahan mana yang kita anggap dapat diandalkan.

Persiapan

Langkah pertama adalah membuat SAML SP dan SAML IdP berkomunikasi satu sama lain.

  1. Unduh perangkat lunaknya. Perangkat lunak sampel untuk artikel ini ada di github (opens in a new tab). Tahapan yang berbeda disimpan di cabang yang berbeda, untuk tahap ini Anda memerlukan saml-only

    1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
    2cd 250420-saml-ethereum
    3pnpm install
1
22. Buat kunci dengan sertifikat yang ditandatangani sendiri. Ini berarti bahwa kunci tersebut adalah otoritas sertifikatnya sendiri, dan perlu diimpor secara manual ke penyedia layanan. Lihat [dokumentasi OpenSSL](https://docs.openssl.org/master/man1/openssl-req/) untuk informasi lebih lanjut.
3
4 ```sh
5 mkdir keys
6 cd keys
7 openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
8 openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
9 cd ..
Tampilkan semua
  1. Mulai server (baik SP maupun IdP)

    1pnpm start
1
24. Buka SP di URL [http://localhost:3000/](http://localhost:3000/) dan klik tombol untuk diarahkan ke IdP (port 3001).
3
45. Berikan alamat email Anda kepada IdP dan klik **Login to the service provider**. Lihat bahwa Anda diarahkan kembali ke penyedia layanan (port 3000) dan bahwa ia mengenali Anda dari alamat email Anda.
5
6### Penjelasan terperinci
7
8Inilah yang terjadi, langkah demi langkah:
9
10![Logon SAML normal tanpa Ethereum](./fig-04-saml-no-eth.png)
11
12#### src/config.mts
13
14File ini berisi konfigurasi untuk Penyedia Identitas dan Penyedia Layanan. Biasanya keduanya akan menjadi entitas yang berbeda, tetapi di sini kita dapat berbagi kode demi kesederhanaan.
15
16```typescript
17const fs = await import("fs")
18
19const protocol="http"
Tampilkan semua

Untuk saat ini kita hanya melakukan pengujian, jadi tidak masalah menggunakan HTTP.

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

Baca kunci publik, yang biasanya tersedia untuk kedua komponen (dan baik dipercaya secara langsung, atau ditandatangani oleh otoritas sertifikat tepercaya).

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}`
Tampilkan semua

URL untuk kedua komponen.

1export const spPublicData = {

Data publik untuk penyedia layanan.

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

Berdasarkan konvensi, dalam SAML entityID adalah URL tempat metadata entitas tersedia. Metadata ini sesuai dengan data publik di sini, kecuali dalam bentuk 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 }
Tampilkan semua

Definisi yang paling penting untuk tujuan kita adalah assertionConsumerServer. Ini berarti bahwa untuk menegaskan sesuatu (misalnya, "pengguna yang mengirimi Anda informasi ini adalah somebody@example.com (opens email client)") kepada penyedia layanan, kita perlu menggunakan HTTP POST (opens in a new tab) ke 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 }
Tampilkan semua

Data publik untuk penyedia identitas juga serupa. Ini menentukan bahwa untuk memasukkan pengguna, Anda melakukan POST ke http://localhost:3001/idp/login dan untuk mengeluarkan pengguna, Anda melakukan POST ke http://localhost:3001/idp/logout.

src/sp.mts

Ini adalah kode yang mengimplementasikan penyedia layanan.

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

Kita menggunakan pustaka samlify (opens in a new tab) untuk mengimplementasikan SAML.

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

Pustaka samlify mengharapkan adanya paket yang memvalidasi bahwa XML sudah benar, ditandatangani dengan kunci publik yang diharapkan, dll. Kita menggunakan @authenio/samlify-node-xmllint (opens in a new tab) untuk tujuan ini.

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

Sebuah Router (opens in a new tab) express (opens in a new tab) adalah "situs web mini" yang dapat dipasang di dalam sebuah situs web. Dalam hal ini, kita menggunakannya untuk mengelompokkan semua definisi penyedia layanan bersama-sama.

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

Representasi penyedia layanan itu sendiri adalah semua data publik, dan kunci pribadi yang digunakannya untuk menandatangani informasi.

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

Data publik berisi semua yang perlu diketahui oleh penyedia layanan tentang penyedia identitas.

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

Untuk memungkinkan interoperabilitas dengan komponen SAML lainnya, penyedia layanan dan identitas harus memiliki data publik mereka (disebut metadata) yang tersedia dalam format XML di /metadata.

1spRouter.post(`/assertion`,

Ini adalah halaman yang diakses oleh peramban untuk mengidentifikasi dirinya sendiri. Asersi tersebut mencakup pengidentifikasi pengguna (di sini kita menggunakan alamat email), dan dapat mencakup atribut tambahan. Ini adalah penangan untuk langkah 7 dalam diagram urutan di atas.

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

Anda dapat menggunakan perintah yang dikomentari untuk melihat data XML yang disediakan dalam asersi. Data tersebut dikodekan dengan base64 (opens in a new tab).

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

Urai permintaan masuk dari server identitas.

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

Kirim respons HTML, hanya untuk menunjukkan kepada pengguna bahwa kita telah menerima proses masuk.

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

Beri tahu pengguna jika terjadi kegagalan.

1spRouter.get('/login',

Buat permintaan masuk ketika peramban mencoba mendapatkan halaman ini. Ini adalah penangan untuk langkah 1 dalam diagram urutan di atas.

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

Dapatkan informasi untuk memposting permintaan masuk.

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

Halaman ini mengirimkan formulir (lihat di bawah) secara otomatis. Dengan cara ini pengguna tidak perlu melakukan apa pun untuk diarahkan. Ini adalah langkah 2 dalam diagram urutan di atas.

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

Posting ke loginRequest.entityEndpoint (URL dari titik akhir penyedia identitas).

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

Nama inputnya adalah loginRequest.type (SAMLRequest). Konten untuk bidang tersebut adalah loginRequest.context, yang sekali lagi merupakan XML yang dikodekan dengan base64.

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

Middleware ini (opens in a new tab) membaca isi dari permintaan HTTP (opens in a new tab). Secara default express mengabaikannya, karena sebagian besar permintaan tidak memerlukannya. Kita memerlukannya karena POST memang menggunakan isi.

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

Pasang router di direktori penyedia layanan (/sp).

1app.get("/", (req, res) => {
2 res.send(`
3 <html>
4 <body>
5 <button onClick="document.location.href='${config.spUrl}/login'">
6 Click here to log on
7 </button>
8 </body>
9 </html>
10 `)
11})
Tampilkan semua

Jika peramban mencoba mendapatkan direktori root, berikan tautan ke halaman masuk.

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

Dengarkan spPort dengan aplikasi express ini.

src/idp.mts

Ini adalah penyedia identitas. Ini sangat mirip dengan penyedia layanan, penjelasan di bawah ini adalah untuk bagian-bagian yang berbeda.

1const xmlParser = new (await import("fast-xml-parser")).XMLParser(
2 {
3 ignoreAttributes: false, // Preserve attributes // Pertahankan atribut
4 attributeNamePrefix: "@_", // Prefix for attributes // Awalan untuk atribut
5 }
6)

Kita perlu membaca dan memahami permintaan XML yang kita terima dari penyedia layanan.

1const getLoginPage = requestId => `

Fungsi ini membuat halaman dengan formulir yang dikirimkan secara otomatis yang dikembalikan pada langkah 4 dari diagram urutan di atas.

1<html>
2 <head>
3 <title>Login page</title>
4 </head>
5 <body>
6 <h2>Login page</h2>
7 <form method="post" action="./loginSubmitted">
8 <input type="hidden" name="requestId" value="${requestId}" />
9 Email address: <input name="email" />
10 <br />
11 <button type="Submit">
12 Login to the service provider
13 </button>
Tampilkan semua

Ada dua bidang yang kita kirim ke penyedia layanan:

  1. requestId yang sedang kita tanggapi.
  2. Pengidentifikasi pengguna (kita menggunakan alamat email yang diberikan pengguna untuk saat ini).
1 </form>
2 </body>
3</html>
4
5const idpRouter = express.Router()
6
7idpRouter.post("/loginSubmitted", async (req, res) => {
8 const loginResponse = await idp.createLoginResponse(

Ini adalah penangan untuk langkah 5 dari diagram urutan di atas. idp.createLoginResponse (opens in a new tab) membuat respons masuk.

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

Audiensnya adalah penyedia layanan.

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

Informasi yang diekstrak dari permintaan. Satu parameter yang kita pedulikan dalam permintaan adalah requestId, yang memungkinkan penyedia layanan mencocokkan permintaan dan responsnya.

1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // Ensure signing // Pastikan penandatanganan

Kita memerlukan signingKey untuk memiliki data guna menandatangani respons. Penyedia layanan tidak memercayai permintaan yang tidak ditandatangani.

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

Ini adalah bidang dengan informasi pengguna yang kita kirim kembali ke penyedia layanan.

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})
Tampilkan semua

Sekali lagi, gunakan formulir yang dikirimkan secara otomatis. Ini adalah langkah 6 dari diagram urutan di atas.

1
2// IdP endpoint for login requests // Endpoint IdP untuk permintaan login
3idpRouter.post(`/login`,

Ini adalah titik akhir yang menerima permintaan masuk dari penyedia layanan. Ini adalah penangan untuk langkah 3 dari diagram urutan di atas.

1 async (req, res) => {
2 try {
3 // Workaround because I couldn't get parseLoginRequest to work. // Solusi sementara karena saya tidak bisa membuat parseLoginRequest berfungsi.
4 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req) // 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"]))

Kita seharusnya dapat menggunakan idp.parseLoginRequest (opens in a new tab) untuk membaca ID permintaan autentikasi. Namun, saya tidak dapat membuatnya berfungsi dan tidak sepadan menghabiskan banyak waktu untuk itu, jadi saya hanya menggunakan pengurai XML serbaguna (opens in a new tab). Informasi yang kita butuhkan adalah atribut ID di dalam tag <samlp:AuthnRequest>, yang berada di tingkat atas XML.

Menggunakan tanda tangan Ethereum

Sekarang setelah kita dapat mengirim identitas pengguna ke penyedia layanan, langkah selanjutnya adalah mendapatkan identitas pengguna dengan cara yang tepercaya. Viem memungkinkan kita untuk sekadar meminta alamat pengguna dari dompet, tetapi ini berarti meminta informasi tersebut dari peramban. Kita tidak mengontrol peramban, jadi kita tidak dapat secara otomatis memercayai respons yang kita dapatkan darinya.

Sebagai gantinya, IdP akan mengirimkan string ke peramban untuk ditandatangani. Jika dompet di peramban menandatangani string ini, itu berarti bahwa itu benar-benar alamat tersebut (yaitu, ia mengetahui kunci pribadi yang sesuai dengan alamat tersebut).

Untuk melihat ini beraksi, hentikan IdP dan SP yang ada dan jalankan perintah ini:

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

Kemudian buka SP (opens in a new tab) dan ikuti petunjuknya.

Perhatikan bahwa pada titik ini kita tidak tahu cara mendapatkan alamat email dari alamat Ethereum, jadi sebagai gantinya kita melaporkan <ethereum address>@bad.email.address ke SP.

Penjelasan terperinci

Perubahannya ada pada langkah 4-5 dalam diagram sebelumnya.

SAML dengan tanda tangan Ethereum

Satu-satunya file yang kita ubah adalah idp.mts. Berikut adalah bagian-bagian yang diubah.

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

Kita memerlukan dua pustaka tambahan ini. Kita menggunakan uuid (opens in a new tab) untuk membuat nilai nonce (opens in a new tab). Nilainya sendiri tidak masalah, hanya fakta bahwa ia hanya digunakan sekali.

Pustaka viem (opens in a new tab) memungkinkan kita menggunakan definisi Ethereum. Di sini kita memerlukannya untuk memverifikasi bahwa tanda tangan tersebut memang valid.

1const loginPrompt = "To access the service provider, sign this nonce: "

Dompet meminta izin kepada pengguna untuk menandatangani pesan. Pesan yang hanya berupa nonce dapat membingungkan pengguna, jadi kita menyertakan prompt ini.

1// Keep requestIDs here // Simpan requestIDs di sini
2let nonces = {}

Kita memerlukan informasi permintaan untuk dapat menanggapinya. Kita dapat mengirimkannya bersama permintaan (langkah 4), dan menerimanya kembali (langkah 5). Namun, kita tidak dapat memercayai informasi yang kita dapatkan dari peramban, yang berada di bawah kendali pengguna yang berpotensi bermusuhan. Jadi lebih baik menyimpannya di sini, dengan nonce sebagai kunci.

Perhatikan bahwa kita melakukannya di sini sebagai variabel demi kesederhanaan. Namun, ini memiliki beberapa kelemahan:

  • Kita rentan terhadap serangan penolakan layanan. Pengguna yang jahat dapat mencoba masuk berkali-kali, memenuhi memori kita.
  • Jika proses IdP perlu dimulai ulang, kita kehilangan nilai yang ada.
  • Kita tidak dapat melakukan penyeimbangan beban di beberapa proses, karena masing-masing akan memiliki variabelnya sendiri.

Pada sistem produksi, kita akan menggunakan basis data dan mengimplementasikan semacam mekanisme kedaluwarsa.

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

Buat nonce, dan simpan requestId untuk penggunaan di masa mendatang.

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

JavaScript ini dieksekusi secara otomatis saat halaman dimuat.

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

Kita memerlukan beberapa fungsi dari viem.

1 if (!window.ethereum) {
2 alert("Please install MetaMask or a compatible wallet and then reload")
3 }

Kita hanya dapat bekerja jika ada dompet di peramban.

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

Minta daftar akun dari dompet (window.ethereum). Asumsikan setidaknya ada satu, dan hanya simpan yang pertama.

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

Buat klien dompet (opens in a new tab) untuk berinteraksi dengan dompet peramban.

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

Minta pengguna untuk menandatangani pesan. Karena seluruh HTML ini berada dalam string templat (opens in a new tab), kita dapat menggunakan variabel yang didefinisikan dalam proses idp. Ini adalah langkah 4.5 dalam diagram urutan.

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

Arahkan ke /idp/signature/<nonce>/<address>/<signature>. Ini adalah langkah 5 dalam diagram urutan.

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 }

Tanda tangan dikirim kembali oleh peramban, yang berpotensi berbahaya (tidak ada yang menghentikan Anda untuk sekadar membuka http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature di peramban). Oleh karena itu, penting untuk memverifikasi bahwa proses IdP menangani tanda tangan yang buruk dengan benar.

1 </script>
2 </head>
3 <body>
4 <h2>Please sign</h2>
5 <button onClick="window.goodSignature()">
6 Submit a good (valid) signature
7 </button>
8 <br/>
9 <button onClick="window.badSignature()">
10 Submit a bad (invalid) signature
11 </button>
12 </body>
13</html>
14`
15}
Tampilkan semua

Sisanya hanyalah HTML standar.

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

Ini adalah penangan untuk langkah 5 dalam diagram urutan.

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

Dapatkan ID permintaan, dan hapus nonce dari nonces untuk memastikan ia tidak dapat digunakan kembali.

1 try {

Karena ada begitu banyak cara di mana tanda tangan bisa menjadi tidak valid, kita membungkus ini dalam blok try ... catch untuk menangkap kesalahan apa pun yang dilemparkan.

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

Gunakan verifyMessage (opens in a new tab) untuk mengimplementasikan langkah 5.5 dalam diagram urutan.

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

Sisa penangan ini setara dengan apa yang telah kita lakukan di penangan /loginSubmitted sebelumnya, kecuali untuk satu perubahan kecil.

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

Kita tidak memiliki alamat email yang sebenarnya (kita akan mendapatkannya di bagian selanjutnya), jadi untuk saat ini kita mengembalikan alamat Ethereum dan menandainya dengan jelas sebagai bukan alamat email.

1// IdP endpoint for login requests // Endpoint IdP untuk permintaan login
2idpRouter.post(`/login`,
3 async (req, res) => {
4 try {
5 // Workaround because I couldn't get parseLoginRequest to work. // Solusi sementara karena saya tidak bisa membuat parseLoginRequest berfungsi.
6 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req) // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
7 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
8 res.send(getSignaturePage(samlRequest["samlp:AuthnRequest"]["@_ID"]))
9 } catch (err) {
10 console.error('Error processing SAML response:', err);
11 res.status(400).send('SAML authentication failed');
12 }
13 }
14)
Tampilkan semua

Alih-alih getLoginPage, sekarang gunakan getSignaturePage di penangan langkah 3.

Mendapatkan alamat email

Langkah selanjutnya adalah mendapatkan alamat email, pengidentifikasi yang diminta oleh penyedia layanan. Untuk melakukannya, kita menggunakan Ethereum Attestation Service (EAS) (opens in a new tab).

Cara termudah untuk mendapatkan pengesahan adalah dengan menggunakan GraphQL API (opens in a new tab). Kita menggunakan kueri ini:

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}
Tampilkan semua

schemaId (opens in a new tab) ini hanya menyertakan alamat email. Kueri ini meminta pengesahan dari skema ini. Subjek pengesahan disebut recipient. Ini selalu berupa alamat Ethereum.

Peringatan: Cara kita mendapatkan pengesahan di sini memiliki dua masalah keamanan.

  • Kita menuju ke titik akhir API, https://optimism.easscan.org/graphql, yang merupakan komponen terpusat. Kita bisa mendapatkan atribut id dan kemudian melakukan pencarian onchain untuk memverifikasi bahwa pengesahan itu nyata, tetapi titik akhir API masih dapat menyensor pengesahan dengan tidak memberi tahu kita tentangnya.

    Masalah ini bukan tidak mungkin untuk dipecahkan, kita dapat menjalankan titik akhir GraphQL kita sendiri dan mendapatkan pengesahan dari log rantai, tetapi itu berlebihan untuk tujuan kita.

  • Kita tidak melihat identitas attester. Siapa pun dapat memberi kita informasi palsu. Dalam implementasi dunia nyata, kita akan memiliki sekumpulan attester tepercaya dan hanya melihat pengesahan mereka.

Untuk melihat ini beraksi, hentikan IdP dan SP yang ada dan jalankan perintah ini:

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

Kemudian berikan alamat email Anda. Anda memiliki dua cara untuk melakukannya:

  • Impor dompet menggunakan kunci pribadi, dan gunakan kunci pribadi pengujian 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

  • Tambahkan pengesahan untuk alamat email Anda sendiri:

    1. Buka skema di penjelajah pengesahan (opens in a new tab).

    2. Klik Attest with Schema.

    3. Masukkan alamat Ethereum Anda sebagai penerima, alamat email Anda sebagai alamat email, dan pilih Onchain. Kemudian klik Make Attestation.

    4. Setujui transaksi di dompet Anda. Anda akan memerlukan sejumlah ETH di Blockchain Optimism (opens in a new tab) untuk membayar gas.

Apa pun caranya, setelah Anda melakukan ini, buka http://localhost:3000 (opens in a new tab) dan ikuti petunjuknya. Jika Anda mengimpor kunci pribadi pengujian, email yang Anda terima adalah test_addr_0@example.com. Jika Anda menggunakan alamat Anda sendiri, itu harus sesuai dengan apa yang Anda sahkan.

Penjelasan terperinci

Mendapatkan dari alamat Ethereum ke email

Langkah-langkah baru adalah komunikasi GraphQL, langkah 5.6 dan 5.7.

Sekali lagi, berikut adalah bagian-bagian yang diubah dari idp.mts.

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

Impor pustaka yang kita butuhkan.

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

Terdapat titik akhir terpisah untuk setiap blockchain (opens in a new tab).

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

Buat klien GraphQLClient baru yang dapat kita gunakan untuk menanyakan titik akhir.

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

GraphQL hanya memberi kita objek data buram dengan byte. Untuk memahaminya kita memerlukan skema.

1const ethereumAddressToEmail = async ethAddr => {

Fungsi untuk mendapatkan dari alamat Ethereum ke alamat email.

1 const query = `
2 query GetAttestationsByRecipient {

Ini adalah kueri GraphQL.

1 attestations(

Kita mencari pengesahan.

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

Pengesahan yang kita inginkan adalah yang ada di skema kita, di mana penerimanya adalah getAddress(ethAddr). Fungsi getAddress (opens in a new tab) memastikan alamat kita memiliki checksum (opens in a new tab) yang benar. Ini diperlukan karena GraphQL peka terhadap huruf besar/kecil. "0xBAD060A7", "0xBad060A7", dan "0xbad060a7" adalah nilai yang berbeda.

1 take: 1

Terlepas dari berapa banyak pengesahan yang kita temukan, kita hanya menginginkan yang pertama.

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

Bidang-bidang yang ingin kita terima.

  • attester: Alamat yang mengirimkan pengesahan. Biasanya ini digunakan untuk memutuskan apakah akan memercayai pengesahan atau tidak.
  • id: ID pengesahan. Anda dapat menggunakan nilai ini untuk membaca pengesahan onchain (opens in a new tab) untuk memverifikasi bahwa informasi dari kueri GraphQL sudah benar.
  • data: Data skema (dalam hal ini, alamat email).
1 const queryResult = await graphqlClient.request(query)
2
3 if (queryResult.attestations.length == 0)
4 return "no_address@available.is"

Jika tidak ada pengesahan, kembalikan nilai yang jelas-jelas salah, tetapi akan tampak valid bagi penyedia layanan.

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

Jika ada nilai, gunakan decodeData untuk mendekode data. Kita tidak memerlukan metadata yang disediakannya, hanya nilainya saja.

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 );
Tampilkan semua

Gunakan fungsi baru untuk mendapatkan alamat email.

Bagaimana dengan desentralisasi?

Dalam konfigurasi ini pengguna tidak dapat berpura-pura menjadi orang lain, selama kita mengandalkan attester yang dapat dipercaya untuk pemetaan alamat Ethereum ke email. Namun, penyedia identitas kita masih merupakan komponen terpusat. Siapa pun yang memiliki kunci pribadi penyedia identitas dapat mengirimkan informasi palsu ke penyedia layanan.

Mungkin ada solusi menggunakan komputasi multi-pihak (MPC) (opens in a new tab). Saya berharap dapat menulis tentang hal itu di tutorial mendatang.

Kesimpulan

Adopsi standar masuk, seperti tanda tangan Ethereum, menghadapi masalah ayam dan telur. Penyedia layanan ingin menarik pasar seluas mungkin. Pengguna ingin dapat mengakses layanan tanpa harus khawatir tentang dukungan standar masuk mereka. Membuat adaptor, seperti Ethereum IdP, dapat membantu kita mengatasi rintangan ini.

Lihat di sini untuk karya saya yang lain (opens in a new tab).

Pembaruan terakhir halaman: 23 November 2025

Apakah tutorial ini membantu?