메인 콘텐츠로 건너뛰기

웹2 인증에 이더리움 사용하기

웹2
인증
eas
초보자
Ori Pomerantz
2025년 4월 30일
35 1분의 읽기 소요시간

소개

SAML (opens in a new tab)은 웹2에서 ID 공급자(IdP) (opens in a new tab)서비스 공급자(SP) (opens in a new tab)에 대한 사용자 정보를 제공할 수 있도록 하는 데 사용되는 표준입니다.

이 튜토리얼에서는 이더리움 서명을 SAML과 통합하여 사용자가 아직 이더리움을 기본적으로 지원하지 않는 웹2 서비스에 대해 이더리움 지갑을 사용하여 자신을 인증할 수 있도록 하는 방법을 배웁니다.

이 튜토리얼은 두 가지 별개의 독자 그룹을 위해 작성되었습니다.

  • 이더리움을 이해하고 SAML을 배워야 하는 이더리움 사용자
  • SAML 및 웹2 인증을 이해하고 이더리움을 배워야 하는 웹2 사용자

따라서 이미 알고 있는 많은 입문 자료가 포함될 것입니다. 자유롭게 건너뛰어도 좋습니다.

이더리움 사용자를 위한 SAML

SAML은 중앙화된 프로토콜입니다. 서비스 공급자(SP)는 해당 ID 공급자(IdP) 또는 해당 IdP의 인증서에 서명한 인증 기관 (opens in a new tab)과 사전 신뢰 관계가 있는 경우에만 ID 공급자로부터 어설션(예: "이 사용자는 John이고, 그는 A, B, C를 수행할 수 있는 권한을 가져야 합니다")을 수락합니다.

예를 들어, SP는 회사에 여행 서비스를 제공하는 여행사일 수 있고 IdP는 회사의 내부 웹사이트일 수 있습니다. 직원들이 출장을 예약해야 할 때, 여행사는 실제로 여행을 예약하기 전에 회사에 인증을 보내도록 합니다.

SAML 단계별 프로세스

이것은 브라우저, SP, IdP 세 엔티티가 액세스를 위해 협상하는 방식입니다. SP는 사전에 브라우저를 사용하는 사용자에 대해 아무것도 알 필요 없이 IdP를 신뢰하기만 하면 됩니다.

SAML 사용자를 위한 이더리움

이더리움은 탈중앙화된 시스템입니다.

이더리움 로그온

사용자는 개인 키를 가집니다(일반적으로 브라우저 확장 프로그램에 보관됨). 개인 키에서 공개 키를 파생할 수 있고, 공개 키에서 20바이트 주소를 파생할 수 있습니다. 사용자가 시스템에 로그인해야 할 때, 논스(일회용 값)가 포함된 메시지에 서명하도록 요청받습니다. 서버는 해당 주소에 의해 서명이 생성되었는지 확인할 수 있습니다.

인증에서 추가 데이터 가져오기

서명은 이더리움 주소만 확인합니다. 다른 사용자 속성을 얻으려면 일반적으로 인증 (opens in a new tab)을 사용합니다. 인증에는 일반적으로 다음과 같은 필드가 있습니다.

  • 인증자, 인증을 만든 주소
  • 수신자, 인증이 적용되는 주소
  • 데이터, 이름, 권한 등과 같이 인증되는 데이터입니다.
  • 스키마, 데이터를 해석하는 데 사용되는 스키마의 ID입니다.

이더리움의 탈중앙화된 특성 때문에 모든 사용자가 인증을 생성할 수 있습니다. 신뢰할 수 있는 인증을 식별하려면 인증자의 신원이 중요합니다.

설정

첫 번째 단계는 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. URL http://localhost:3000/ (opens in a new tab)에서 SP로 이동하여 버튼을 클릭하면 IdP(포트 3001)로 리디렉션됩니다.

  5. IdP에 이메일 주소를 제공하고 서비스 공급자에 로그인을 클릭합니다. 서비스 공급자(포트 3000)로 다시 리디렉션되고 이메일 주소로 사용자를 인식하는지 확인합니다.

자세한 설명

다음은 단계별로 일어나는 일입니다.

이더리움 없는 일반 SAML 로그온

src/config.mts

이 파일에는 ID 공급자와 서비스 공급자 모두에 대한 구성이 포함되어 있습니다. 일반적으로 이 둘은 서로 다른 엔티티이지만 여기서는 단순화를 위해 코드를 공유할 수 있습니다.

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입니다. 이는 서비스 공급자에게 무언가를 주장하기 위해(예: "이 정보를 보내는 사용자는 somebody@example.com (opens email client)입니다") HTTP POST (opens in a new tab)를 사용하여 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 }
모두 보기

ID 공급자의 공개 데이터는 비슷합니다. 사용자를 로그인하려면 http://localhost:3001/idp/login에 POST하고 사용자를 로그아웃하려면 http://localhost:3001/idp/logout에 POST하도록 지정합니다.

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

공개 데이터에는 서비스 공급자가 ID 공급자에 대해 알아야 할 모든 것이 포함됩니다.

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

다른 SAML 구성 요소와의 상호 운용성을 활성화하려면 서비스 및 ID 공급자의 공개 데이터(메타데이터라고 함)를 /metadata의 XML 형식으로 사용할 수 있어야 합니다.

1spRouter.post(`/assertion`,

이것은 브라우저가 자신을 식별하기 위해 액세스하는 페이지입니다. 어설션에는 사용자 식별자(여기서는 이메일 주소를 사용)가 포함되며 추가 속성을 포함할 수 있습니다. 위 시퀀스 다이어그램의 7단계에 대한 핸들러입니다.

1 async (req, res) => {
2 // console.log(`SAML response:\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);

ID 서버에서 로그인 요청을 구문 분석합니다.

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(ID 공급자 엔드포인트의 URL)에 게시합니다.

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

입력 이름은 loginRequest.type(SAMLRequest)입니다. 해당 필드의 내용은 loginRequest.context이며, 다시 base64로 인코딩된 XML입니다.

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

이 express 애플리케이션으로 spPort를 수신합니다.

src/idp.mts

ID 공급자입니다. 서비스 공급자와 매우 유사하며 아래 설명은 다른 부분에 대한 것입니다.

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

서비스 공급자로부터 수신하는 XML 요청을 읽고 이해해야 합니다.

1const getLoginPage = requestId => `

이 함수는 위 시퀀스 다이어그램의 4단계에서 반환되는 자동 제출 양식이 있는 페이지를 만듭니다.

1<html>
2 <head>
3 <title>로그인 페이지</title>
4 </head>
5 <body>
6 <h2>로그인 페이지</h2>
7 <form method="post" action="./loginSubmitted">
8 <input type="hidden" name="requestId" value="${requestId}" />
9 이메일 주소: <input name="email" />
10 <br />
11 <button type="Submit">
12 서비스 공급자에 로그인
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 } // Ensure signing

응답에 서명할 데이터를 가지려면 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 endpoint for login requests
3idpRouter.post(`/login`,

서비스 공급자로부터 로그인 요청을 수신하는 엔드포인트입니다. 위 시퀀스 다이어그램의 3단계에 대한 핸들러입니다.

1 async (req, res) => {
2 try {
3 // Workaround because I couldn't get parseLoginRequest to work.
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"]))

인증 요청의 ID를 읽으려면 idp.parseLoginRequest (opens in a new tab)를 사용할 수 있어야 합니다. 하지만 작동하게 할 수 없었고 많은 시간을 할애할 가치가 없었기 때문에 범용 XML 파서 (opens in a new tab)를 사용했습니다. 필요한 정보는 XML의 최상위 수준에 있는 <samlp:AuthnRequest> 태그 내의 ID 속성입니다.

이더리움 서명 사용하기

이제 서비스 공급자에게 사용자 ID를 보낼 수 있으므로 다음 단계는 신뢰할 수 있는 방식으로 사용자 ID를 얻는 것입니다. Viem을 사용하면 지갑에 사용자 주소를 요청할 수 있지만, 이는 브라우저에 정보를 요청하는 것을 의미합니다. 브라우저를 제어할 수 없으므로 브라우저에서 받은 응답을 자동으로 신뢰할 수 없습니다.

대신 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)를 사용하여 논스 (opens in a new tab) 값을 생성합니다. 값 자체는 중요하지 않고 한 번만 사용된다는 사실만 중요합니다.

viem (opens in a new tab) 라이브러리를 사용하면 이더리움 정의를 사용할 수 있습니다. 여기서는 서명이 실제로 유효한지 확인해야 합니다.

1const loginPrompt = "서비스 공급자에 액세스하려면 이 논스에 서명하세요: "

지갑은 사용자에게 메시지에 서명할 권한을 요청합니다. 논스만 있는 메시지는 사용자를 혼란스럽게 할 수 있으므로 이 프롬프트를 포함합니다.

1// Keep requestIDs here
2let nonces = {}

응답하려면 요청 정보가 필요합니다. 요청(4단계)과 함께 보내고 다시 받을 수 있습니다(5단계). 그러나 잠재적으로 적대적인 사용자의 통제하에 있는 브라우저에서 얻는 정보는 신뢰할 수 없습니다. 따라서 여기에 nonce를 키로 저장하는 것이 좋습니다.

여기서는 단순성을 위해 변수로 수행하고 있음을 참고하세요. 그러나 여기에는 몇 가지 단점이 있습니다.

  • 서비스 거부 공격에 취약합니다. 악의적인 사용자가 여러 번 로그온을 시도하여 메모리를 채울 수 있습니다.
  • IdP 프로세스를 다시 시작해야 하는 경우 기존 값을 잃게 됩니다.
  • 각각 고유한 변수를 가지므로 여러 프로세스에 걸쳐 부하를 분산할 수 없습니다.

프로덕션 시스템에서는 데이터베이스를 사용하고 일종의 만료 메커니즘을 구현합니다.

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

논스를 만들고 나중에 사용할 수 있도록 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("MetaMask 또는 호환되는 지갑을 설치한 다음 다시 로드하세요")
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)에 있으므로 idp 프로세스에 정의된 변수를 사용할 수 있습니다. 시퀀스 다이어그램의 4.5단계입니다.

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

/idp/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>서명하세요</h2>
5 <button onClick="window.goodSignature()">
6 좋은(유효한) 서명 제출
7 </button>
8 <br/>
9 <button onClick="window.badSignature()">
10 잘못된(유효하지 않은) 서명 제출
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

요청 ID를 가져오고 재사용할 수 없도록 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 })

시퀀스 다이어그램의 5.5단계를 구현하려면 verifyMessage (opens in a new tab)를 사용합니다.

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)
모두 보기

3단계 핸들러에서 getLoginPage 대신 이제 getSignaturePage를 사용합니다.

이메일 주소 받기

다음 단계는 서비스 공급자가 요청한 식별자인 이메일 주소를 얻는 것입니다. 이를 위해 이더리움 증명 서비스(EAS) (opens in a new tab)를 사용합니다.

인증을 얻는 가장 쉬운 방법은 GraphQL API (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)는 이메일 주소만 포함합니다. 이 쿼리는 이 스키마의 인증을 요청합니다. 인증의 주체는 recipient라고 합니다. 항상 이더리움 주소입니다.

경고: 여기서 인증을 얻는 방식에는 두 가지 보안 문제가 있습니다.

  • 중앙화된 구성 요소인 API 엔드포인트 https://optimism.easscan.org/graphql로 이동합니다. id 속성을 가져와서 온체인 조회를 통해 인증이 진짜인지 확인할 수 있지만, API 엔드포인트는 인증에 대해 알려주지 않음으로써 여전히 인증을 검열할 수 있습니다.

    이 문제는 해결 불가능한 것이 아니며, 자체 GraphQL 엔드포인트를 실행하고 체인 로그에서 인증을 얻을 수 있지만, 우리의 목적에는 과도합니다.

  • 인증자 신원을 보지 않습니다. 누구나 거짓 정보를 제공할 수 있습니다. 실제 구현에서는 신뢰할 수 있는 인증자 집합이 있고 그들의 인증만 살펴봅니다.

이것이 작동하는 것을 보려면 기존 IdP와 SP를 중지하고 다음 명령을 실행하세요.

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

그런 다음 이메일 주소를 제공합니다. 두 가지 방법이 있습니다.

  • 개인 키를 사용하여 지갑을 가져오고 테스트 개인 키 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80을 사용합니다.

  • 자신의 이메일 주소에 대한 인증을 추가합니다.

    1. 인증 탐색기에서 스키마 (opens in a new tab)로 이동합니다.

    2. 스키마로 인증을 클릭합니다.

    3. 이더리움 주소를 수신자로 입력하고, 이메일 주소를 이메일 주소로 입력하고, 온체인을 선택합니다. 그런 다음 인증 만들기를 클릭합니다.

    4. 지갑에서 트랜잭션을 승인합니다. 가스를 지불하려면 옵티미즘 블록체인 (opens in a new tab)에 ETH가 필요합니다.

어느 쪽이든 이 작업을 마친 후 http://localhost:3000 (opens in a new tab)으로 이동하여 지침을 따릅니다. 테스트 개인 키를 가져온 경우 받는 이메일은 test_addr_0@example.com입니다. 자신의 주소를 사용했다면 인증한 주소여야 합니다.

자세한 설명

이더리움 주소에서 이메일로 가져오기

새로운 단계는 GraphQL 통신, 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)

GraphQL은 바이트가 포함된 불투명한 데이터 객체만 제공합니다. 이를 이해하려면 스키마가 필요합니다.

1const ethereumAddressToEmail = async ethAddr => {

이더리움 주소에서 이메일 주소로 변환하는 함수입니다.

1 const query = `
2 query GetAttestationsByRecipient {

GraphQL 쿼리입니다.

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)이 있는지 확인합니다. 이는 GraphQL이 대소문자를 구분하기 때문에 필요합니다. "0xBAD060A7", "0xBad060A7", "0xbad060a7"은 서로 다른 값입니다.

1 take: 1

몇 개의 인증을 찾든 첫 번째 것만 원합니다.

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

받고 싶은 필드입니다.

  • attester: 인증을 제출한 주소. 일반적으로 인증을 신뢰할지 여부를 결정하는 데 사용됩니다.
  • id: 인증 ID. 이 값을 사용하여 온체인에서 인증을 읽어 (opens in a new tab) GraphQL 쿼리의 정보가 올바른지 확인할 수 있습니다.
  • 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 );
모두 보기

새 함수를 사용하여 이메일 주소를 가져옵니다.

탈중앙화는 어떻습니까?

이 구성에서 사용자는 이더리움-이메일 주소 매핑을 위해 신뢰할 수 있는 인증자에 의존하는 한 다른 사람인 척할 수 없습니다. 그러나 ID 공급자는 여전히 중앙화된 구성 요소입니다. ID 공급자의 개인 키를 가진 사람은 누구나 서비스 공급자에게 거짓 정보를 보낼 수 있습니다.

다자간 계산(MPC) (opens in a new tab)를 사용하는 솔루션이 있을 수 있습니다. 다음 튜토리얼에서 이에 대해 쓸 수 있기를 바랍니다.

결론

이더리움 서명과 같은 로그온 표준을 채택하는 것은 닭과 달걀 문제에 직면합니다. 서비스 공급자는 가능한 가장 넓은 시장에 어필하기를 원합니다. 사용자는 로그온 표준 지원에 대해 걱정하지 않고 서비스에 액세스할 수 있기를 원합니다. 이더리움 IdP와 같은 어댑터를 만들면 이 장애물을 극복하는 데 도움이 될 수 있습니다.

여기서 제 작업에 대한 자세한 내용을 확인하세요 (opens in a new tab).

페이지 마지막 업데이트됨: 2025년 11월 23일

이 튜토리얼이 도움이 되셨나요?