تخطٍ إلى المحتوى الرئيسي

استخدام إيثريوم للمصادقة على الويب 2

ويب2
المصادقة
eas
المستوى المبتدئ
أوري بوميرانتز
30 أبريل 2025
18 دقيقة قراءة

مقدمة

SAML (opens in a new tab) هو معيار يُستخدم في الويب 2 للسماح لموفر الهوية (IdP) (opens in a new tab) بتوفير معلومات المستخدم لمقدمي الخدمة (SP) (opens in a new tab).

في هذا البرنامج التعليمي، ستتعلم كيفية دمج توقيعات إيثريوم مع SAML للسماح للمستخدمين باستخدام محافظ إيثريوم الخاصة بهم لمصادقة أنفسهم على خدمات الويب 2 التي لا تدعم إيثريوم أصلاً حتى الآن.

لاحظ أن هذا البرنامج التعليمي مكتوب لجمهورين منفصلين:

  • مستخدمو إيثريوم الذين يفهمون إيثريوم ويحتاجون إلى تعلم SAML
  • مستخدمو الويب 2 الذين يفهمون SAML ومصادقة الويب 2 ويحتاجون إلى تعلم إيثريوم

نتيجة لذلك، سيحتوي على الكثير من المواد التمهيدية التي تعرفها بالفعل. لا تتردد في تخطيها.

SAML لمستخدمي إيثريوم

SAML هو بروتوكول مركزي. لا يقبل مقدم الخدمة (SP) التأكيدات (مثل \

على سبيل المثال، يمكن أن يكون مقدم الخدمة (SP) وكالة سفر تقدم خدمات السفر للشركات، ويمكن أن يكون موفر الهوية (IdP) موقع الويب الداخلي للشركة. عندما يحتاج الموظفون إلى حجز سفر عمل، ترسلهم وكالة السفر للمصادقة من قبل الشركة قبل السماح لهم بحجز السفر فعليًا.

عملية SAML خطوة بخطوة

هذه هي الطريقة التي تتفاوض بها الكيانات الثلاثة، المتصفح، ومقدم الخدمة (SP)، وموفر الهوية (IdP)، للوصول. لا يحتاج مقدم الخدمة (SP) إلى معرفة أي شيء عن المستخدم الذي يستخدم المتصفح مسبقًا، فقط أن يثق في موفر الهوية (IdP).

إيثريوم لمستخدمي SAML

إيثريوم نظام لامركزي.

تسجيل الدخول باستخدام إيثريوم

يمتلك المستخدمون مفتاحًا خاصًا (يُحتفظ به عادةً في ملحق متصفح). من المفتاح الخاص يمكنك اشتقاق مفتاح عام، ومنه عنوان بحجم 20 بايت. عندما يحتاج المستخدمون إلى تسجيل الدخول إلى نظام ما، يُطلب منهم توقيع رسالة باستخدام nonce (قيمة تستخدم مرة واحدة). يمكن للخادم التحقق من أن التوقيع تم إنشاؤه بواسطة ذلك العنوان.

الحصول على بيانات إضافية من المصادقات

يتحقق التوقيع فقط من عنوان إيثريوم. للحصول على سمات المستخدم الأخرى، تستخدم عادةً المصادقات (opens in a new tab). تحتوي المصادقة عادةً على هذه الحقول:

  • المُصادِق، العنوان الذي قام بالمصادقة
  • المستلم، العنوان الذي تنطبق عليه المصادقة
  • البيانات، البيانات التي يتم المصادقة عليها، مثل الاسم، الأذونات، إلخ.
  • المخطط، معرف المخطط المستخدم لتفسير البيانات.

بسبب الطبيعة اللامركزية لإيثريوم، يمكن لأي مستخدم إجراء مصادقات. هوية المُصادِق مهمة لتحديد أي المصادقات نعتبرها موثوقة.

الإعداد

الخطوة الأولى هي أن يكون لديك مقدم خدمة SAML (SP) وموفر هوية SAML (IdP) يتواصلان فيما بينهما.

  1. قم بتنزيل البرنامج. البرنامج النموذجي لهذه المقالة موجود على github (opens in a new tab). يتم تخزين المراحل المختلفة في فروع مختلفة، لهذه المرحلة تريد saml-only

    1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only
    2cd 250420-saml-ethereum
    3pnpm install
  2. أنشئ مفاتيح بشهادات موقعة ذاتيًا. هذا يعني أن المفتاح هو هيئة إصدار الشهادات الخاصة به، ويحتاج إلى استيراده يدويًا إلى مقدم الخدمة. راجع مستندات OpenSSL (opens in a new tab) لمزيد من المعلومات.

    1mkdir keys
    2cd keys
    3openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/
    4openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/
    5cd ..
  3. ابدأ الخوادم (كل من SP و IdP)

    1pnpm start
  4. تصفح مقدم الخدمة (SP) على الرابط http://localhost:3000/ (opens in a new tab) وانقر على الزر ليتم إعادة توجيهك إلى موفر الهوية (IdP) (المنفذ 3001).

  5. زوّد موفر الهوية (IdP) بعنوان بريدك الإلكتروني وانقر على تسجيل الدخول إلى مقدم الخدمة. سترى أنه يتم إعادة توجيهك مرة أخرى إلى مقدم الخدمة (المنفذ 3000) وأنه يعرفك من خلال عنوان بريدك الإلكتروني.

شرح مفصل

هذا ما يحدث خطوة بخطوة:

تسجيل دخول SAML العادي بدون إيثريوم

src/config.mts

يحتوي هذا الملف على تكوين كل من موفر الهوية ومقدم الخدمة. عادةً ما يكون هذان الكيانان مختلفين، ولكن هنا يمكننا مشاركة النص البرمجي للتبسيط.

1const fs = await import("fs")
2
3const protocol="http"

في الوقت الحالي نحن نختبر فقط، لذلك لا بأس من استخدام HTTP.

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

اقرأ المفاتيح العامة، والتي تكون متاحة عادةً لكلا المكونين (إما موثوق بها مباشرة، أو موقعة من قبل هيئة إصدار شهادات موثوقة).

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}`
إظهار الكل

عناوين URL لكلا المكونين.

1export const spPublicData = {

البيانات العامة لمقدم الخدمة.

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

حسب الاصطلاح، في SAML، يكون entityID هو عنوان URL حيث تتوفر البيانات الوصفية للكيان. تتوافق هذه البيانات الوصفية مع البيانات العامة هنا، باستثناء أنها في شكل 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 }
إظهار الكل

التعريف الأكثر أهمية لأغراضنا هو assertionConsumerServer. هذا يعني أنه لتأكيد شيء ما (على سبيل المثال، \

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 }
إظهار الكل

البيانات العامة لموفر الهوية مماثلة. يحدد أنه لتسجيل دخول مستخدم، تقوم بإجراء POST إلى http://localhost:3001/idp/login ولتسجيل خروج مستخدم، تقوم بإجراء POST إلى http://localhost:3001/idp/logout.

src/sp.mts

هذا هو النص البرمجي الذي ينفذ مقدم الخدمة.

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

نحن نستخدم مكتبة samlify (opens in a new tab) لتنفيذ SAML.

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

تتوقع مكتبة samlify وجود حزمة للتحقق من أن XML صحيح، وموقع بالمفتاح العام المتوقع، إلخ. نحن نستخدم @authenio/samlify-node-xmllint (opens in a new tab) لهذا الغرض.

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

إن express (opens in a new tab) Router (opens in a new tab) هو \ في هذه الحالة، نستخدمه لتجميع كل تعريفات مقدم الخدمة معًا.

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

التمثيل الخاص لمقدم الخدمة لنفسه هو كل البيانات العامة، والمفتاح الخاص الذي يستخدمه لتوقيع المعلومات.

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

تحتوي البيانات العامة على كل ما يحتاجه مقدم الخدمة لمعرفته حول موفر الهوية.

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

لتمكين التشغيل البيني مع مكونات SAML الأخرى، يجب أن تكون البيانات العامة لمقدمي الخدمة والهوية (تسمى البيانات الوصفية) متاحة بتنسيق XML في /metadata.

1spRouter.post(`/assertion`,

هذه هي الصفحة التي يصل إليها المتصفح لتعريف نفسه. يتضمن التأكيد معرف المستخدم (هنا نستخدم عنوان البريد الإلكتروني)، ويمكن أن يتضمن سمات إضافية. هذا هو المعالج للخطوة 7 في مخطط التسلسل أعلاه.

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

يمكنك استخدام الأمر المعلق لرؤية بيانات XML المقدمة في التأكيد. إنه مشفر بـ base64 (opens in a new tab).

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

حلل طلب تسجيل الدخول من خادم الهوية.

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

أرسل استجابة HTML، فقط لإظهار للمستخدم أننا حصلنا على تسجيل الدخول.

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

أبلغ المستخدم في حالة الفشل.

1spRouter.get('/login',

أنشئ طلب تسجيل دخول عندما يحاول المتصفح الحصول على هذه الصفحة. هذا هو المعالج للخطوة 1 في مخطط التسلسل أعلاه.

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

احصل على المعلومات لنشر طلب تسجيل دخول.

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

هذه الصفحة تقدم النموذج (انظر أدناه) تلقائيًا. بهذه الطريقة، لا يتعين على المستخدم فعل أي شيء لإعادة توجيهه. هذه هي الخطوة 2 في مخطط التسلسل أعلاه.

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

انشر إلى loginRequest.entityEndpoint (عنوان URL لنقطة نهاية موفر الهوية).

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

اسم الإدخال هو loginRequest.type (SAMLRequest). المحتوى لهذا الحقل هو loginRequest.context، وهو مرة أخرى XML مشفر بـ base64.

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

هذا البرنامج الوسيط (opens in a new tab) يقرأ نص طلب HTTP (opens in a new tab). بشكل افتراضي، يتجاهله express، لأن معظم الطلبات لا تتطلبه. نحن بحاجة إليه لأن POST يستخدم النص.

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

حمّل جهاز التوجيه في دليل مقدم الخدمة (/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})
إظهار الكل

إذا حاول المتصفح الحصول على الدليل الجذر، فزوده برابط إلى صفحة تسجيل الدخول.

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

استمع إلى spPort باستخدام تطبيق express هذا.

src/idp.mts

هذا هو موفر الهوية. إنه مشابه جدًا لمقدم الخدمة، والتوضيحات أدناه للأجزاء المختلفة.

1const xmlParser = new (await import("fast-xml-parser")).XMLParser(
2 {
3 ignoreAttributes: false, // الحفاظ على السمات
4 attributeNamePrefix: "@_", // بادئة للسمات
5 }
6)

نحتاج إلى قراءة وفهم طلب XML الذي نتلقاه من مقدم الخدمة.

1const getLoginPage = requestId => `

تنشئ هذه الوظيفة الصفحة مع النموذج المقدم تلقائيًا والذي يتم إرجاعه في الخطوة 4 من مخطط التسلسل أعلاه.

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>
إظهار الكل

هناك حقلان نرسلهما إلى مقدم الخدمة:

  1. requestId الذي نرد عليه.
  2. معرف المستخدم (نستخدم عنوان البريد الإلكتروني الذي يوفره المستخدم في الوقت الحالي).
1 </form>
2 </body>
3</html>
4
5const idpRouter = express.Router()
6
7idpRouter.post("/loginSubmitted", async (req, res) => {
8 const loginResponse = await idp.createLoginResponse(

هذا هو المعالج للخطوة 5 في مخطط التسلسل أعلاه. idp.createLoginResponse (opens in a new tab) ينشئ استجابة تسجيل الدخول.

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

الجمهور هو مقدم الخدمة.

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

المعلومات المستخرجة من الطلب. المعلمة الوحيدة التي نهتم بها في الطلب هي requestId، والتي تتيح لمقدم الخدمة مطابقة الطلبات واستجاباتها.

1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // تأكد من التوقيع

نحن بحاجة إلى signingKey للحصول على البيانات لتوقيع الاستجابة. لا يثق مقدم الخدمة في الطلبات غير الموقعة.

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

هذا هو الحقل الذي يحتوي على معلومات المستخدم التي نرسلها مرة أخرى إلى مقدم الخدمة.

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})
إظهار الكل

مرة أخرى، استخدم نموذجًا يتم تقديمه تلقائيًا. هذه هي الخطوة 6 في مخطط التسلسل أعلاه.

1
2// نقطة نهاية IdP لطلبات تسجيل الدخول
3idpRouter.post(`/login`,

هذه هي نقطة النهاية التي تتلقى طلب تسجيل دخول من مقدم الخدمة. هذا هو المعالج للخطوة 3 في مخطط التسلسل أعلاه.

1 async (req, res) => {
2 try {
3 // حل بديل لأنني لم أتمكن من تشغيل parseLoginRequest.
4 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)
5 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))
6 res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))

يجب أن نكون قادرين على استخدام idp.parseLoginRequest (opens in a new tab) لقراءة معرف طلب المصادقة. ومع ذلك، لم أتمكن من تشغيله ولم يكن الأمر يستحق قضاء الكثير من الوقت عليه، لذلك استخدمت فقط محلل XML للأغراض العامة (opens in a new tab). المعلومات التي نحتاجها هي سمة ID داخل علامة <samlp:AuthnRequest>، والتي تقع في المستوى الأعلى من XML.

استخدام توقيعات إيثريوم

الآن بعد أن أصبح بإمكاننا إرسال هوية المستخدم إلى مقدم الخدمة، فإن الخطوة التالية هي الحصول على هوية المستخدم بطريقة موثوقة. يسمح لنا فيم بطلب عنوان المستخدم من المحفظة مباشرةً، ولكن هذا يعني طلب المعلومات من المتصفح. نحن لا نتحكم في المتصفح، لذلك لا يمكننا الوثوق تلقائيًا بالاستجابة التي نحصل عليها منه.

بدلاً من ذلك، سيرسل موفر الهوية (IdP) سلسلة للمتصفح لتوقيعها. إذا قامت المحفظة في المتصفح بتوقيع هذه السلسلة، فهذا يعني أنها بالفعل ذلك العنوان (أي أنها تعرف المفتاح الخاص الذي يتوافق مع العنوان).

لرؤية هذا أثناء العمل، أوقف موفر الهوية (IdP) ومقدم الخدمة (SP) الحاليين وقم بتشغيل هذه الأوامر:

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

ثم تصفح إلى مقدم الخدمة (SP) (opens in a new tab) واتبع الإرشادات.

لاحظ أننا في هذه المرحلة لا نعرف كيفية الحصول على عنوان البريد الإلكتروني من عنوان إيثريوم، لذلك بدلاً من ذلك نبلغ <عنوان إيثريوم>@bad.email.address إلى مقدم الخدمة (SP).

شرح مفصل

التغييرات في الخطوتين 4-5 في الرسم التخطيطي السابق.

SAML مع توقيع إيثريوم

الملف الوحيد الذي غيرناه هو idp.mts. فيما يلي الأجزاء التي تم تغييرها.

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

نحن بحاجة إلى هاتين المكتبتين الإضافيتين. نحن نستخدم uuid (opens in a new tab) لإنشاء قيمة nonce (opens in a new tab). القيمة نفسها لا تهم، فقط حقيقة أنها تستخدم مرة واحدة فقط.

تتيح لنا مكتبة viem (opens in a new tab) استخدام تعريفات إيثريوم. هنا نحتاجه للتحقق من أن التوقيع صالح بالفعل.

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

تطلب المحفظة من المستخدم الإذن لتوقيع الرسالة. قد تؤدي الرسالة التي هي مجرد nonce إلى إرباك المستخدمين، لذلك نقوم بتضمين هذا الموجه.

1// احتفظ بمعرفات الطلب هنا
2let nonces = {}

نحن بحاجة إلى معلومات الطلب لنتمكن من الرد عليه. يمكننا إرسالها مع الطلب (الخطوة 4)، واستلامها مرة أخرى (الخطوة 5). ومع ذلك، لا يمكننا الوثوق بالمعلومات التي نحصل عليها من المتصفح، والتي تخضع لسيطرة مستخدم قد يكون معاديًا. لذلك من الأفضل تخزينها هنا، مع استخدام nonce كمفتاح.

لاحظ أننا نفعل ذلك هنا كمتغير من أجل التبسيط. ومع ذلك، فإن هذا له عدة عيوب:

  • نحن عرضة لهجوم حجب الخدمة. يمكن لمستخدم ضار محاولة تسجيل الدخول عدة مرات، مما يملأ ذاكرتنا.
  • إذا احتاجت عملية موفر الهوية (IdP) إلى إعادة التشغيل، فإننا نفقد القيم الموجودة.
  • لا يمكننا موازنة التحميل عبر عمليات متعددة، لأن كل عملية سيكون لها متغيرها الخاص.

في نظام الإنتاج، سنستخدم قاعدة بيانات ونطبق نوعًا من آلية انتهاء الصلاحية.

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

أنشئ nonce، وقم بتخزين requestId للاستخدام المستقبلي.

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

يتم تنفيذ هذا الجافا سكريبت تلقائيًا عند تحميل الصفحة.

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

نحن بحاجة إلى عدة وظائف من viem.

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

لا يمكننا العمل إلا إذا كانت هناك محفظة في المتصفح.

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

اطلب قائمة الحسابات من المحفظة (window.ethereum). افترض أن هناك حسابًا واحدًا على الأقل، وقم بتخزين الحساب الأول فقط.

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

أنشئ عميل محفظة (opens in a new tab) للتفاعل مع محفظة المتصفح.

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

اطلب من المستخدم توقيع رسالة. نظرًا لأن كل هذا الـ HTML موجود في قالب نصي (opens in a new tab)، يمكننا استخدام المتغيرات المحددة في عملية موفر الهوية. هذه هي الخطوة 4.5 في مخطط التسلسل.

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

أعد التوجيه إلى /idp/signature/<nonce>/<address>/<signature>. هذه هي الخطوة 5 في مخطط التسلسل.

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 }

يتم إرسال التوقيع مرة أخرى بواسطة المتصفح، والذي قد يكون ضارًا (لا يوجد ما يمنعك من مجرد فتح http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature في المتصفح). لذلك، من المهم التحقق من أن عملية موفر الهوية (IdP) تتعامل مع التوقيعات السيئة بشكل صحيح.

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}
إظهار الكل

الباقي هو مجرد HTML قياسي.

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

هذا هو المعالج للخطوة 5 في مخطط التسلسل.

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

احصل على معرف الطلب، واحذف الـ nonce من nonces للتأكد من عدم إمكانية إعادة استخدامه.

1 try {

نظرًا لوجود العديد من الطرق التي يمكن أن يكون بها التوقيع غير صالح، فإننا نلف هذا في try ... كتلة catch لالتقاط أي أخطاء تم طرحها.

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

استخدم verifyMessage (opens in a new tab) لتنفيذ الخطوة 5.5 في مخطط التسلسل.

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

بقية المعالج مكافئ لما فعلناه في معالج /loginSubmitted سابقًا، باستثناء تغيير صغير واحد.

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

ليس لدينا عنوان البريد الإلكتروني الفعلي (سنحصل عليه في القسم التالي)، لذلك في الوقت الحالي نعيد عنوان إيثريوم ونميزه بوضوح على أنه ليس عنوان بريد إلكتروني.

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

بدلاً من getLoginPage، استخدم الآن getSignaturePage في معالج الخطوة 3.

الحصول على عنوان البريد الإلكتروني

الخطوة التالية هي الحصول على عنوان البريد الإلكتروني، المعرف الذي يطلبه مقدم الخدمة. للقيام بذلك، نستخدم خدمة توثيق إيثريوم (EAS) (opens in a new tab).

أسهل طريقة للحصول على المصادقات هي استخدام واجهة برمجة تطبيقات جراف كيو إل (opens in a new tab). نستخدم هذا الاستعلام:

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}
إظهار الكل

يتضمن schemaId (opens in a new tab) هذا عنوان بريد إلكتروني فقط. يطلب هذا الاستعلام مصادقات لهذا المخطط. يُطلق على موضوع المصادقة اسم المستلم. إنه دائمًا عنوان إيثريوم.

تحذير: الطريقة التي نحصل بها على المصادقات هنا بها مشكلتان أمنيتان.

  • نحن نذهب إلى نقطة نهاية واجهة برمجة التطبيقات https://optimism.easscan.org/graphql، وهي مكون مركزي. يمكننا الحصول على سمة id ثم إجراء بحث على السلسلة للتحقق من أن المصادقة حقيقية، ولكن لا يزال بإمكان نقطة نهاية واجهة برمجة التطبيقات فرض رقابة على المصادقات من خلال عدم إخبارنا بها.

    هذه المشكلة ليست مستحيلة الحل، يمكننا تشغيل نقطة نهاية جراف كيو إل الخاصة بنا والحصول على المصادقات من سجلات السلسلة، ولكن هذا مفرط بالنسبة لأغراضنا.

  • نحن لا ننظر إلى هوية المصادق. يمكن لأي شخص أن يزودنا بمعلومات كاذبة. في تطبيق العالم الحقيقي، سيكون لدينا مجموعة من المصادقين الموثوق بهم وننظر فقط في مصادقاتهم.

لرؤية هذا أثناء العمل، أوقف موفر الهوية (IdP) ومقدم الخدمة (SP) الحاليين وقم بتشغيل هذه الأوامر:

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

ثم قم بتوفير عنوان بريدك الإلكتروني. لديك طريقتان للقيام بذلك:

  • استورد محفظة باستخدام مفتاح خاص، واستخدم المفتاح الخاص للاختبار 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

  • أضف مصادقة لعنوان بريدك الإلكتروني الخاص:

    1. تصفح المخطط في مستكشف المصادقات (opens in a new tab).

    2. انقر على المصادقة باستخدام المخطط.

    3. أدخل عنوان إيثريوم الخاص بك كمستلم، وعنوان بريدك الإلكتروني كعنوان بريد إلكتروني، وحدد على السلسلة. ثم انقر على إجراء المصادقة.

    4. وافق على المعاملة في محفظتك. ستحتاج إلى بعض ETH على بلوكتشين أوبتيميزم (opens in a new tab) لدفع ثمن الغاز.

في كلتا الحالتين، بعد القيام بذلك، تصفح http://localhost:3000 (opens in a new tab) واتبع التعليمات. إذا قمت باستيراد مفتاح الاختبار الخاص، فإن البريد الإلكتروني الذي تتلقاه هو test_addr_0@example.com. إذا كنت قد استخدمت عنوانك الخاص، فيجب أن يكون هو ما صادقت عليه.

شرح مفصل

الحصول على البريد الإلكتروني من عنوان إيثريوم

الخطوات الجديدة هي اتصال جراف كيو إل، الخطوتان 5.6 و 5.7.

مرة أخرى، إليك الأجزاء التي تم تغييرها من idp.mts.

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

استورد المكتبات التي نحتاجها.

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

هناك نقطة نهاية منفصلة لكل بلوكتشين (opens in a new tab).

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

أنشئ عميل GraphQLClient جديدًا يمكننا استخدامه للاستعلام عن نقطة النهاية.

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

يعطينا جراف كيو إل فقط كائن بيانات غير شفاف مع بايتات. لفهم ذلك نحن بحاجة إلى المخطط.

1const ethereumAddressToEmail = async ethAddr => {

وظيفة للانتقال من عنوان إيثريوم إلى عنوان بريد إلكتروني.

1 const query = `
2 query GetAttestationsByRecipient {

هذا استعلام جراف كيو إل.

1 attestations(

نحن نبحث عن المصادقات.

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

المصادقات التي نريدها هي تلك الموجودة في مخططنا، حيث يكون المستلم هو getAddress(ethAddr). تتأكد دالة getAddress (opens in a new tab) من أن عنواننا يحتوي على المجموع الاختباري (opens in a new tab) الصحيح. هذا ضروري لأن جراف كيو إل حساس لحالة الأحرف.

1 take: 1

بغض النظر عن عدد المصادقات التي نجدها، نريد فقط المصادقة الأولى.

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

الحقول التي نريد استلامها.

  • المصادق: العنوان الذي قدم المصادقة. عادةً ما يتم استخدام هذا لتحديد ما إذا كنت تثق في المصادقة أم لا.
  • id: معرف المصادقة. يمكنك استخدام هذه القيمة لقراءة المصادقة على السلسلة (opens in a new tab) للتحقق من صحة المعلومات من استعلام جراف كيو إل.
  • data: بيانات المخطط (في هذه الحالة، عنوان البريد الإلكتروني).
1 const queryResult = await graphqlClient.request(query)
2
3 if (queryResult.attestations.length == 0)
4 return "no_address@available.is"

إذا لم تكن هناك مصادقة، فقم بإرجاع قيمة غير صحيحة بشكل واضح، ولكنها ستبدو صالحة لمزود الخدمة.

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

إذا كانت هناك قيمة، فاستخدم decodeData لفك تشفير البيانات. لسنا بحاجة إلى البيانات الوصفية التي توفرها، فقط القيمة نفسها.

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 );
إظهار الكل

استخدم الوظيفة الجديدة للحصول على عنوان البريد الإلكتروني.

ماذا عن اللامركزية؟

في هذا التكوين، لا يمكن للمستخدمين التظاهر بأنهم شخص آخر ليسوا هم، طالما أننا نعتمد على مصادقين جديرين بالثقة لربط عنوان إيثريوم بعنوان البريد الإلكتروني. ومع ذلك، لا يزال موفر الهوية الخاص بنا مكونًا مركزيًا. يمكن لأي شخص لديه المفتاح الخاص لموفر الهوية إرسال معلومات خاطئة إلى مقدم الخدمة.

قد يكون هناك حل باستخدام الحوسبة متعددة الأطراف (MPC) (opens in a new tab). آمل أن أكتب عن ذلك في برنامج تعليمي مستقبلي.

الخاتمة

يواجه تبني معيار تسجيل الدخول، مثل توقيعات إيثريوم، مشكلة الدجاجة والبيضة. يريد مقدمو الخدمات جذب أوسع سوق ممكن. يريد المستخدمون أن يكونوا قادرين على الوصول إلى الخدمات دون الحاجة إلى القلق بشأن دعم معيار تسجيل الدخول الخاص بهم. يمكن أن يساعدنا إنشاء محولات، مثل موفر هوية إيثريوم، في التغلب على هذه العقبة.

انظر هنا لمزيد من أعمالي (opens in a new tab).

آخر تحديث للصفحة: 23 نوفمبر 2025

هل كانت تعليمات الاستخدام هذه مفيدة؟