Chuyển đến nội dung chính

Sử dụng Ethereum để xác thực Web2

Web2
xác thực
eas
Người mới bắt đầu
Ori Pomerantz
30 tháng 4, 2025
25 phút đọc

Giới thiệu

SAML (opens in a new tab) là một tiêu chuẩn được sử dụng trên Web2 để cho phép một nhà cung cấp danh tính (IdP) (opens in a new tab) cung cấp thông tin người dùng cho các [nhà cung cấp dịch vụ (SP)](https://en.wikipedia.org/wiki/Service_provider_(SAML) (opens in a new tab).

Trong hướng dẫn này, bạn sẽ tìm hiểu cách tích hợp chữ ký Ethereum với SAML để cho phép người dùng sử dụng Ví Ethereum của họ để tự xác thực với các dịch vụ Web2 chưa hỗ trợ Ethereum một cách nguyên bản.

Lưu ý rằng hướng dẫn này được viết cho hai đối tượng độc giả riêng biệt:

  • Những người dùng Ethereum đã hiểu về Ethereum và cần tìm hiểu về SAML
  • Những người dùng Web2 đã hiểu về SAML và xác thực Web2 và cần tìm hiểu về Ethereum

Do đó, bài viết sẽ chứa nhiều tài liệu giới thiệu mà bạn có thể đã biết. Bạn có thể thoải mái bỏ qua phần đó.

SAML dành cho người dùng Ethereum

SAML là một Giao thức tập trung. Một nhà cung cấp dịch vụ (SP) chỉ chấp nhận các khẳng định (chẳng hạn như "đây là người dùng John của tôi, anh ấy nên có quyền thực hiện A, B và C") từ một nhà cung cấp danh tính (IdP) nếu nó có mối quan hệ tin cậy từ trước với IdP đó, hoặc với tổ chức phát hành chứng chỉ (opens in a new tab) đã ký chứng chỉ của IdP đó.

Ví dụ: SP có thể là một đại lý du lịch cung cấp dịch vụ du lịch cho các công ty và IdP có thể là trang web nội bộ của một công ty. Khi nhân viên cần đặt chuyến công tác, đại lý du lịch sẽ gửi họ đi xác thực bởi công ty trước khi thực sự cho phép họ đặt chuyến đi.

Step by step SAML process

Đây là cách ba thực thể: trình duyệt, SP và IdP, đàm phán để cấp quyền truy cập. SP không cần biết trước bất kỳ điều gì về người dùng đang sử dụng trình duyệt, chỉ cần tin tưởng IdP.

Ethereum dành cho người dùng SAML

Ethereum là một hệ thống phi tập trung.

Ethereum logon

Người dùng có một khóa riêng tư (thường được lưu giữ trong một tiện ích mở rộng trình duyệt). Từ khóa riêng tư, bạn có thể tạo ra một khóa công khai và từ đó tạo ra một Địa chỉ 20 byte. Khi người dùng cần đăng nhập vào một hệ thống, họ được yêu cầu ký một thông điệp với một nonce (một giá trị sử dụng một lần). Máy chủ có thể xác minh chữ ký đã được tạo bởi Địa chỉ đó.

Getting extra data from attestations

chữ ký chỉ xác minh Địa chỉ Ethereum. Để lấy các thuộc tính người dùng khác, bạn thường sử dụng các chứng thực (opens in a new tab). Một chứng thực thường có các trường sau:

  • Attestor (Người chứng thực), Địa chỉ đã thực hiện chứng thực
  • Recipient (Người nhận), Địa chỉ mà chứng thực áp dụng
  • Data (Dữ liệu), dữ liệu đang được chứng thực, chẳng hạn như tên, quyền hạn, v.v.
  • Schema (Lược đồ), ID của lược đồ được sử dụng để diễn giải dữ liệu.

Do bản chất phi tập trung của Ethereum, bất kỳ người dùng nào cũng có thể thực hiện các chứng thực. Danh tính của người chứng thực rất quan trọng để xác định những chứng thực nào mà chúng ta coi là đáng tin cậy.

Thiết lập

Bước đầu tiên là để một SAML SP và một SAML IdP giao tiếp với nhau.

  1. Tải xuống phần mềm. Phần mềm mẫu cho bài viết này có trên GitHub (opens in a new tab). Các giai đoạn khác nhau được lưu trữ trong các nhánh khác nhau, đối với giai đoạn này, bạn cần saml-only

    git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
    cd 250420-saml-ethereum
    pnpm install
    
  2. Tạo các khóa với chứng chỉ tự ký. Điều này có nghĩa là khóa tự đóng vai trò là tổ chức phát hành chứng chỉ của chính nó và cần được nhập thủ công vào nhà cung cấp dịch vụ. Xem tài liệu OpenSSL (opens in a new tab) để biết thêm thông tin.

    mkdir keys
    cd keys
    openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
    openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
    cd ..
    
  3. Khởi động các máy chủ (cả SP và IdP)

    pnpm start
    
  4. Duyệt đến SP tại URL http://localhost:3000/ (opens in a new tab) và nhấp vào nút để được chuyển hướng đến IdP (cổng 3001).

  5. Cung cấp cho IdP địa chỉ email của bạn và nhấp vào Login to the service provider (Đăng nhập vào nhà cung cấp dịch vụ). Bạn sẽ thấy mình được chuyển hướng trở lại nhà cung cấp dịch vụ (cổng 3000) và nó nhận ra bạn qua địa chỉ email của bạn.

Giải thích chi tiết

Dưới đây là những gì diễn ra, theo từng bước:

Normal SAML logon without Ethereum

src/config.mts

Tệp này chứa cấu hình cho cả Nhà cung cấp danh tính (IdP) và Nhà cung cấp dịch vụ (SP). Thông thường hai thành phần này sẽ là các thực thể khác nhau, nhưng ở đây chúng ta có thể chia sẻ mã để đơn giản hóa.

const fs = await import("fs")

const protocol="http"

Hiện tại chúng ta chỉ đang thử nghiệm, vì vậy việc sử dụng HTTP là hoàn toàn ổn.

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

Đọc các khóa công khai, thường có sẵn cho cả hai thành phần (và được tin cậy trực tiếp hoặc được ký bởi một tổ chức phát hành chứng chỉ đáng tin cậy).

Các URL cho cả hai thành phần.

export const spPublicData = {

Dữ liệu công khai cho nhà cung cấp dịch vụ.

    entityID: `${spUrl}/metadata`,

Theo quy ước, trong SAML, entityID là URL nơi siêu dữ liệu của thực thể có sẵn. siêu dữ liệu này tương ứng với dữ liệu công khai ở đây, ngoại trừ việc nó ở định dạng XML.

Định nghĩa quan trọng nhất cho mục đích của chúng ta là assertionConsumerServer. Điều đó có nghĩa là để khẳng định một điều gì đó (ví dụ: "người dùng gửi cho bạn thông tin này là somebody@example.com (opens email client)") với nhà cung cấp dịch vụ, chúng ta cần sử dụng HTTP POST (opens in a new tab) tới URL http://localhost:3000/sp/assertion.

Dữ liệu công khai cho nhà cung cấp danh tính cũng tương tự. Nó chỉ định rằng để đăng nhập cho một người dùng, bạn POST tới http://localhost:3001/idp/login và để đăng xuất một người dùng, bạn POST tới http://localhost:3001/idp/logout.

src/sp.mts

Đây là mã triển khai một nhà cung cấp dịch vụ.

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

Chúng ta sử dụng Thư viện samlify (opens in a new tab) để triển khai SAML.

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

Thư viện samlify yêu cầu phải có một gói xác thực rằng XML là chính xác, được ký bằng khóa công khai dự kiến, v.v. Chúng ta sử dụng @authenio/samlify-node-xmllint (opens in a new tab) cho mục đích này.

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

Một Router (opens in a new tab) của express (opens in a new tab) là một "trang web thu nhỏ" có thể được gắn bên trong một trang web. Trong trường hợp này, chúng ta sử dụng nó để nhóm tất cả các định nghĩa của nhà cung cấp dịch vụ lại với nhau.

const spPrivateKey = fs.readFileSync("keys/saml-sp.pem").toString()

const sp = saml.ServiceProvider({
  privateKey: spPrivateKey,  
  ...config.spPublicData
})

Đại diện riêng của nhà cung cấp dịch vụ về chính nó là tất cả dữ liệu công khai và khóa riêng tư mà nó sử dụng để ký thông tin.

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

Dữ liệu công khai chứa mọi thứ mà nhà cung cấp dịch vụ cần biết về nhà cung cấp danh tính.

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

Để kích hoạt khả năng tương tác với các thành phần SAML khác, các nhà cung cấp dịch vụ và danh tính nên có dữ liệu công khai của họ (được gọi là siêu dữ liệu) ở định dạng XML trong /metadata.

spRouter.post(`/assertion`,

Đây là trang được trình duyệt truy cập để tự nhận dạng. Khẳng định bao gồm định danh người dùng (ở đây chúng ta sử dụng địa chỉ email) và có thể bao gồm các thuộc tính bổ sung. Đây là trình xử lý cho bước 7 trong biểu đồ trình tự ở trên.

  async (req, res) => {
    // console.log(`Phản hồi SAML:\n${Buffer.from(req.body.SAMLResponse, 'base64').toString('utf-8')}`)

Bạn có thể sử dụng lệnh đã được chú thích để xem dữ liệu XML được cung cấp trong khẳng định. Nó được mã hóa base64 (opens in a new tab).

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

Phân tích cú pháp yêu cầu đăng nhập từ máy chủ danh tính.

      res.send(`
        <html>
          <body>
            <h2>Hello ${loginResponse.extract.nameID}</h2>
          </body>
        </html>
      `)
      res.send();

Gửi một phản hồi HTML, chỉ để cho người dùng thấy chúng ta đã nhận được đăng nhập.

    } catch (err) {
      console.error('Error processing SAML response:', err);
      res.status(400).send('SAML authentication failed');
    }
  }
)

Thông báo cho người dùng trong trường hợp thất bại.

spRouter.get('/login',

Tạo một yêu cầu đăng nhập khi trình duyệt cố gắng lấy trang này. Đây là trình xử lý cho bước 1 trong biểu đồ trình tự ở trên.

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

Lấy thông tin để đăng một yêu cầu đăng nhập.

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

Trang này tự động gửi biểu mẫu (xem bên dưới). Bằng cách này, người dùng không phải làm bất cứ điều gì để được chuyển hướng. Đây là bước 2 trong biểu đồ trình tự ở trên.

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

POST tới loginRequest.entityEndpoint (URL của điểm cuối nhà cung cấp danh tính).

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

Tên đầu vào là loginRequest.type (SAMLRequest). Nội dung cho trường đó là loginRequest.context, một lần nữa là XML được mã hóa base64.

          </form>
        </body>
      </html>
    `)    
  }
)

app.use(express.urlencoded({extended: true}))

Phần mềm trung gian này (opens in a new tab) đọc phần thân của yêu cầu HTTP (opens in a new tab). Theo mặc định, express bỏ qua nó, vì hầu hết các yêu cầu không cần đến nó. Chúng ta cần nó vì POST có sử dụng phần thân.

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

Gắn bộ định tuyến vào thư mục nhà cung cấp dịch vụ (/sp).

Nếu một trình duyệt cố gắng lấy thư mục gốc, hãy cung cấp cho nó một liên kết đến trang đăng nhập.

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

Lắng nghe spPort với ứng dụng express này.

src/idp.mts

Đây là nhà cung cấp danh tính. Nó rất giống với nhà cung cấp dịch vụ, các giải thích bên dưới dành cho những phần khác biệt.

const xmlParser = new (await import("fast-xml-parser")).XMLParser(
  {
    ignoreAttributes: false, // Giữ nguyên các thuộc tính
    attributeNamePrefix: "@_", // Tiền tố cho các thuộc tính
  }
)

Chúng ta cần đọc và hiểu yêu cầu XML mà chúng ta nhận được từ nhà cung cấp dịch vụ.

const getLoginPage = requestId => `

Hàm này tạo trang với biểu mẫu tự động gửi được trả về trong bước 4 của biểu đồ trình tự ở trên.

Có hai trường mà chúng ta gửi cho nhà cung cấp dịch vụ:

  1. requestId mà chúng ta đang phản hồi.
  2. Định danh người dùng (hiện tại chúng ta sử dụng địa chỉ email mà người dùng cung cấp).
    </form>
  </body>
</html>

const idpRouter = express.Router()

idpRouter.post("/loginSubmitted", async (req, res) => {
  const loginResponse = await idp.createLoginResponse(

Đây là trình xử lý cho bước 5 của biểu đồ trình tự ở trên. idp.createLoginResponse (opens in a new tab) tạo phản hồi đăng nhập.

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

Đối tượng tiếp nhận là nhà cung cấp dịch vụ.

      extract: {
        request: {
          id: req.body.requestId
        }
      },

Thông tin được trích xuất từ yêu cầu. Tham số duy nhất mà chúng ta quan tâm trong yêu cầu là requestId, cho phép nhà cung cấp dịch vụ khớp các yêu cầu với các phản hồi của chúng.

      signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert }  // Đảm bảo việc ký

Chúng ta cần signingKey để có dữ liệu ký phản hồi. Nhà cung cấp dịch vụ không tin tưởng các yêu cầu chưa được ký.

    },
    "post",
    {
      email: req.body.email

Đây là trường chứa thông tin người dùng mà chúng ta gửi lại cho nhà cung cấp dịch vụ.

Một lần nữa, sử dụng một biểu mẫu tự động gửi. Đây là bước 6 của biểu đồ trình tự ở trên.


// Điểm cuối IdP cho các yêu cầu đăng nhập
idpRouter.post(`/login`,

Đây là điểm cuối nhận yêu cầu đăng nhập từ nhà cung cấp dịch vụ. Đây là trình xử lý cho bước 3 của biểu đồ trình tự ở trên.

  async (req, res) => {
    try {
      // Giải pháp thay thế vì tôi không thể làm cho parseLoginRequest hoạt động.
      // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
      const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
      res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))

Chúng ta đáng lẽ có thể sử dụng idp.parseLoginRequest (opens in a new tab) để đọc ID của yêu cầu xác thực. Tuy nhiên, tôi không thể làm cho nó hoạt động và không đáng để dành nhiều thời gian cho nó nên tôi chỉ sử dụng một trình phân tích cú pháp XML đa năng (opens in a new tab). Thông tin chúng ta cần là thuộc tính ID bên trong thẻ <samlp:AuthnRequest>, nằm ở cấp cao nhất của XML.

Sử dụng chữ ký Ethereum

Bây giờ chúng ta đã có thể gửi danh tính người dùng đến nhà cung cấp dịch vụ, bước tiếp theo là lấy danh tính người dùng một cách đáng tin cậy. Viem cho phép chúng ta chỉ cần hỏi Ví về Địa chỉ người dùng, nhưng điều này có nghĩa là hỏi trình duyệt để lấy thông tin. Chúng ta không kiểm soát trình duyệt, vì vậy chúng ta không thể tự động tin tưởng phản hồi mà chúng ta nhận được từ nó.

Thay vào đó, IdP sẽ gửi cho trình duyệt một chuỗi để ký. Nếu Ví trong trình duyệt ký chuỗi này, điều đó có nghĩa nó thực sự là Địa chỉ đó (tức là nó biết khóa riêng tư tương ứng với Địa chỉ đó).

Để xem điều này hoạt động như thế nào, hãy dừng IdP và SP hiện tại và chạy các lệnh sau:

git checkout eth-signatures
pnpm install
pnpm start

Sau đó duyệt đến SP (opens in a new tab) và làm theo hướng dẫn.

Lưu ý rằng tại thời điểm này, chúng ta chưa biết cách lấy địa chỉ email từ Địa chỉ Ethereum, vì vậy thay vào đó chúng ta báo cáo <ethereum address>@bad.email.address cho SP.

Giải thích chi tiết

Các thay đổi nằm ở các bước 4-5 trong biểu đồ trước đó.

SAML with an Ethereum signature

Tệp duy nhất chúng ta đã thay đổi là idp.mts. Dưới đây là các phần đã thay đổi.

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

Chúng ta cần thêm hai Thư viện này. Chúng ta sử dụng uuid (opens in a new tab) để tạo giá trị nonce (opens in a new tab). Bản thân giá trị không quan trọng, chỉ cần thực tế là nó chỉ được sử dụng một lần.

Thư viện viem (opens in a new tab) cho phép chúng ta sử dụng các định nghĩa Ethereum. Ở đây chúng ta cần nó để xác minh rằng chữ ký thực sự hợp lệ.

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

Ví yêu cầu người dùng cấp quyền để ký thông điệp. Một thông điệp chỉ là một nonce có thể gây nhầm lẫn cho người dùng, vì vậy chúng ta đưa vào lời nhắc này.

// Giữ các requestID ở đây
let nonces = {}

Chúng ta cần thông tin yêu cầu để có thể phản hồi nó. Chúng ta có thể gửi nó cùng với yêu cầu (bước 4) và nhận lại nó (bước 5). Tuy nhiên, chúng ta không thể tin tưởng thông tin nhận được từ trình duyệt, vốn nằm dưới sự kiểm soát của một người dùng có khả năng gây hại. Vì vậy, tốt hơn là lưu trữ nó ở đây, với nonce làm khóa.

Lưu ý rằng chúng ta đang thực hiện điều đó ở đây dưới dạng một biến vì mục đích đơn giản hóa. Tuy nhiên, điều này có một số nhược điểm:

  • Chúng ta dễ bị tấn công từ chối dịch vụ. Một người dùng độc hại có thể cố gắng đăng nhập nhiều lần, làm đầy bộ nhớ của chúng ta.
  • Nếu tiến trình IdP cần được khởi động lại, chúng ta sẽ mất các giá trị hiện có.
  • Chúng ta không thể cân bằng tải trên nhiều tiến trình, vì mỗi tiến trình sẽ có biến riêng của nó.

Trên một hệ thống sản xuất, chúng ta sẽ sử dụng cơ sở dữ liệu và triển khai một loại cơ chế hết hạn nào đó.

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

Tạo một nonce và lưu trữ requestId để sử dụng trong tương lai.

  return `
<html>
  <head>
    <script type="module">

JavaScript này được thực thi tự động khi trang được tải.

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

Chúng ta cần một số hàm từ viem.

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

Chúng ta chỉ có thể hoạt động nếu có một Ví trên trình duyệt.

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

Yêu cầu danh sách các tài khoản từ Ví (window.ethereum). Giả sử có ít nhất một tài khoản và chỉ lưu trữ tài khoản đầu tiên.

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

Tạo một máy khách Ví (opens in a new tab) để tương tác với Ví trình duyệt.

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

Yêu cầu người dùng ký một thông điệp. Vì toàn bộ HTML này nằm trong một chuỗi mẫu (opens in a new tab), chúng ta có thể sử dụng các biến được định nghĩa trong tiến trình idp. Đây là bước 4.5 trong biểu đồ trình tự.

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

Chuyển hướng đến /idp/signature/<nonce>/<address>/<signature>. Đây là bước 5 trong biểu đồ trình tự.

      window.badSignature = () => {
        const path= "/${config.idpDir}/signature/${nonce}/" + 
          getAddress("0x" + "BAD060A7".padEnd(40, "0")) + 
          "/0x" + "BAD0516".padStart(130, "0")
        window.location.href = path
      }

chữ ký được gửi lại bởi trình duyệt, vốn có khả năng độc hại (không có gì ngăn cản bạn chỉ cần mở http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature trong trình duyệt). Do đó, điều quan trọng là phải xác minh tiến trình IdP xử lý các chữ ký không hợp lệ một cách chính xác.

Phần còn lại chỉ là HTML tiêu chuẩn.

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

Đây là trình xử lý cho bước 5 trong biểu đồ trình tự.

  const requestId = nonces[req.params.nonce]
  if (requestId === undefined) {
    res.send("Bad nonce")
    return ;
  }  
  
  nonces[req.params.nonce] = undefined

Lấy ID yêu cầu và xóa nonce khỏi nonces để đảm bảo nó không thể được sử dụng lại.

  try {

Vì có rất nhiều cách khiến chữ ký có thể không hợp lệ, chúng ta bọc phần này trong một khối try ... catch để bắt bất kỳ lỗi nào được ném ra.

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

Sử dụng verifyMessage (opens in a new tab) để triển khai bước 5.5 trong biểu đồ trình tự.

    if (!validSignature)
      throw("Bad signature")
  } catch (err) {
    res.send("Error:" + err)
    return ;
  }

Phần còn lại của trình xử lý tương đương với những gì chúng ta đã làm trong trình xử lý /loginSubmitted trước đó, ngoại trừ một thay đổi nhỏ.

  const loginResponse = await idp.createLoginResponse(
      .
      .
      .
    {
      email: req.params.account + "@bad.email.address"
    }
  );

Chúng ta không có địa chỉ email thực tế (chúng ta sẽ lấy nó trong phần tiếp theo), vì vậy hiện tại chúng ta trả về Địa chỉ Ethereum và đánh dấu rõ ràng rằng nó không phải là một địa chỉ email.

Thay vì getLoginPage, bây giờ hãy sử dụng getSignaturePage trong trình xử lý bước 3.

Lấy địa chỉ email

Bước tiếp theo là lấy địa chỉ email, định danh được yêu cầu bởi nhà cung cấp dịch vụ. Để làm điều đó, chúng ta sử dụng Dịch vụ chứng thực Ethereum (EAS) (opens in a new tab).

Cách dễ nhất để lấy các chứng thực là sử dụng API GraphQL (opens in a new tab). Chúng ta sử dụng truy vấn này:

schemaId (opens in a new tab) này chỉ bao gồm một địa chỉ email. Truy vấn này yêu cầu các chứng thực của lược đồ này. Đối tượng của chứng thực được gọi là recipient. Nó luôn là một Địa chỉ Ethereum.

Cảnh báo: Cách chúng ta đang lấy các chứng thực ở đây có hai vấn đề về bảo mật.

  • Chúng ta đang đi đến điểm cuối API, https://optimism.easscan.org/graphql, vốn là một thành phần tập trung. Chúng ta có thể lấy thuộc tính id và sau đó thực hiện tra cứu trên chuỗi để xác minh rằng một chứng thực là có thật, nhưng điểm cuối API vẫn có thể kiểm duyệt các chứng thực bằng cách không cho chúng ta biết về chúng.

    Vấn đề này không phải là không thể giải quyết, chúng ta có thể chạy điểm cuối GraphQL của riêng mình và lấy các chứng thực từ các Nhật ký của Chuỗi, nhưng điều đó là quá mức cần thiết cho mục đích của chúng ta.

  • Chúng ta không xem xét danh tính người chứng thực. Bất kỳ ai cũng có thể cung cấp cho chúng ta thông tin sai lệch. Trong một triển khai thực tế, chúng ta sẽ có một tập hợp các người chứng thực đáng tin cậy và chỉ xem xét các chứng thực của họ.

Để xem điều này hoạt động như thế nào, hãy dừng IdP và SP hiện tại và chạy các lệnh sau:

git checkout email-address
pnpm install
pnpm start

Sau đó cung cấp địa chỉ email của bạn. Bạn có hai cách để làm điều đó:

  • Nhập một Ví bằng khóa riêng tư và sử dụng khóa riêng tư thử nghiệm 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

  • Thêm một chứng thực cho địa chỉ email của riêng bạn:

    1. Duyệt đến lược đồ trong trình khám phá chứng thực (opens in a new tab).

    2. Nhấp vào Attest with Schema (Chứng thực với Lược đồ).

    3. Nhập Địa chỉ Ethereum của bạn làm người nhận, địa chỉ email của bạn làm địa chỉ email và chọn Onchain (trên chuỗi). Sau đó nhấp vào Make Attestation (Tạo chứng thực).

    4. chấp thuận giao dịch trong Ví của bạn. Bạn sẽ cần một ít ETH trên Chuỗi khối Optimism (opens in a new tab) để trả phí Gas.

Dù bằng cách nào, sau khi bạn thực hiện việc này, hãy duyệt đến http://localhost:3000 (opens in a new tab) và làm theo hướng dẫn. Nếu bạn đã nhập khóa riêng tư thử nghiệm, email bạn nhận được là test_addr_0@example.com. Nếu bạn đã sử dụng Địa chỉ của riêng mình, nó sẽ là bất cứ thứ gì bạn đã chứng thực.

Giải thích chi tiết

Getting from Ethereum address to e-mail

Các bước mới là giao tiếp GraphQL, các bước 5.6 và 5.7.

Một lần nữa, đây là các phần đã thay đổi của idp.mts.

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

Nhập các Thư viện chúng ta cần.

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

một điểm cuối riêng biệt cho mỗi Chuỗi khối (opens in a new tab).

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

Tạo một máy khách GraphQLClient mới mà chúng ta có thể sử dụng để truy vấn điểm cuối.

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

GraphQL chỉ cung cấp cho chúng ta một đối tượng dữ liệu mờ với các byte. Để hiểu nó, chúng ta cần lược đồ.

const ethereumAddressToEmail = async ethAddr => {

Một hàm để lấy từ một Địa chỉ Ethereum sang một địa chỉ email.

  const query = `
    query GetAttestationsByRecipient {

Đây là một truy vấn GraphQL.

      attestations(

Chúng ta đang tìm kiếm các chứng thực.

        where: { 
          recipient: { equals: "${getAddress(ethAddr)}" }
          schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }
        }

Các chứng thực mà chúng ta muốn là những chứng thực trong lược đồ của chúng ta, nơi người nhận là getAddress(ethAddr). Hàm getAddress (opens in a new tab) đảm bảo Địa chỉ của chúng ta có tổng kiểm tra (opens in a new tab) chính xác. Điều này là cần thiết vì GraphQL phân biệt chữ hoa chữ thường. "0xBAD060A7", "0xBad060A7" và "0xbad060a7" là các giá trị khác nhau.

        take: 1

Bất kể chúng ta tìm thấy bao nhiêu chứng thực, chúng ta chỉ muốn cái đầu tiên.

      ) {
        data
        id
        attester
      }
    }`

Các trường mà chúng ta muốn nhận.

  • attester: Địa chỉ đã gửi chứng thực. Thông thường, điều này được sử dụng để quyết định xem có nên tin tưởng chứng thực hay không.
  • id: ID chứng thực. Bạn có thể sử dụng giá trị này để đọc chứng thực trên chuỗi (opens in a new tab) nhằm xác minh rằng thông tin từ truy vấn GraphQL là chính xác.
  • data: Dữ liệu lược đồ (trong trường hợp này là địa chỉ email).
  const queryResult = await graphqlClient.request(query)

  if (queryResult.attestations.length == 0)
    return "no_address@available.is"

Nếu không có chứng thực, hãy trả về một giá trị rõ ràng là không chính xác, nhưng có vẻ hợp lệ đối với nhà cung cấp dịch vụ.

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

Nếu có một giá trị, hãy sử dụng decodeData để giải mã dữ liệu. Chúng ta không cần siêu dữ liệu mà nó cung cấp, chỉ cần bản thân giá trị đó.

Sử dụng hàm mới để lấy địa chỉ email.

Còn về sự phi tập trung thì sao?

Trong cấu hình này, người dùng không thể giả vờ là một người khác, miễn là chúng ta dựa vào các người chứng thực đáng tin cậy cho việc ánh xạ Địa chỉ Ethereum sang địa chỉ email. Tuy nhiên, nhà cung cấp danh tính của chúng ta vẫn là một thành phần tập trung. Bất kỳ ai có khóa riêng tư của nhà cung cấp danh tính đều có thể gửi thông tin sai lệch cho nhà cung cấp dịch vụ.

Có thể có một giải pháp sử dụng tính toán đa bên (MPC) (opens in a new tab). Tôi hy vọng sẽ viết về nó trong một hướng dẫn tương lai.

Kết luận

Việc áp dụng một tiêu chuẩn đăng nhập, chẳng hạn như chữ ký Ethereum, phải đối mặt với vấn đề con gà và quả trứng. Các nhà cung cấp dịch vụ muốn thu hút thị trường rộng lớn nhất có thể. Người dùng muốn có thể truy cập các dịch vụ mà không phải lo lắng về việc hỗ trợ tiêu chuẩn đăng nhập của họ. Việc tạo ra các bộ điều hợp, chẳng hạn như một IdP Ethereum, có thể giúp chúng ta vượt qua rào cản này.

Xem thêm các bài viết khác của tôi tại đây (opens in a new tab).

Cập nhật trang lần cuối: 3 tháng 4, 2026