使用 SIWE 實現 Ethereum 登入教學|React + Go 實作範例解析

本文教你如何使用 Sign-In with Ethereum(SIWE) 實作去中心化登入機制,包含前端 React 範例與後端 Go 驗證流程。透過 nonce、signature、JWT token 完成 Web3 登入驗證,適合想整合以太坊錢包登入功能的開發者。

CChia1104
筆記4 分鐘閱讀

siwe Ethereum 登入

前端先去跟後端拿一個隨機數(nonce)

get nonce

前端再用這個 nonce 去取得簽名(signature)跟訊息(message)

login req

message 格式

example.com wants you to sign in with your Ethereum account:
12345678

wallet_sign_statement
URI: https://example.com
Version: 1
Chain ID: 1
Nonce: 22ppxlq5
Issued At: 2025-02-19T03:57:48.796Z
Expiration Time: 2025-03-21T03:57:48.796Z

React 範例

SignInButton.tsx
import * as React from 'react'
import { useAccount, useNetwork, useSignMessage } from 'wagmi'
import { SiweMessage } from 'siwe'

function SignInButton({
  onSuccess,
  onError,
}: {
  onSuccess: (args: { address: string }) => void
  onError: (args: { error: Error }) => void
}) {
  const [state, setState] = React.useState<{
    loading?: boolean
    nonce?: string
  }>({})

  const fetchNonce = async () => {
    try {
      const nonceRes = await fetch('/api/nonce')
      const nonce = await nonceRes.text()
      setState((x) => ({ ...x, nonce }))
    } catch (error) {
      setState((x) => ({ ...x, error: error as Error }))
    }
  }

  // Pre-fetch random nonce when button is rendered
  // to ensure deep linking works for WalletConnect
  // users on iOS when signing the SIWE message
  React.useEffect(() => {
    fetchNonce()
  }, [])

  const { address } = useAccount()
  const { chain } = useNetwork()
  const { signMessageAsync } = useSignMessage()

  const signIn = async () => {
    try {
      const chainId = chain?.id
      if (!address || !chainId) return

      setState((x) => ({ ...x, loading: true }))
      // Create SIWE message with pre-fetched nonce and sign with wallet
      const message = new SiweMessage({
        domain: window.location.host,
        address,
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId,
        nonce: state.nonce,
      })
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      })

      // Verify signature
      const verifyRes = await fetch('/api/verify', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message, signature }),
      })
      if (!verifyRes.ok) throw new Error('Error verifying message')

      setState((x) => ({ ...x, loading: false }))
      onSuccess({ address })
    } catch (error) {
      setState((x) => ({ ...x, loading: false, nonce: undefined }))
      onError({ error: error as Error })
      fetchNonce()
    }
  }

  return (
    <button disabled={!state.nonce || state.loading} onClick={signIn}>
      Sign-In with Ethereum
    </button>
  )
}

export function Profile() {
  const { isConnected } = useAccount()

  const [state, setState] = React.useState<{
    address?: string
    error?: Error
    loading?: boolean
  }>({})

  // Fetch user when:
  React.useEffect(() => {
    const handler = async () => {
      try {
        const res = await fetch('/api/me')
        const json = await res.json()
        setState((x) => ({ ...x, address: json.address }))
      } catch (_error) {}
    }
    // 1. page loads
    handler()

    // 2. window is focused (in case user logs out of another window)
    window.addEventListener('focus', handler)
    return () => window.removeEventListener('focus', handler)
  }, [])

  if (isConnected) {
    return (
      <div>
        {/* Account content goes here */}

        {state.address ? (
          <div>
            <div>Signed in as {state.address}</div>
            <button
              onClick={async () => {
                await fetch('/api/logout')
                setState({})
              }}
            >
              Sign Out
            </button>
          </div>
        ) : (
          <SignInButton
            onSuccess={({ address }) => setState((x) => ({ ...x, address }))}
            onError={({ error }) => setState((x) => ({ ...x, error }))}
          />
        )}
      </div>
    )
  }

  return <div>{/* Connect wallet content goes here */}</div>
}

後端

後端需要透過這兩個資訊去解,判斷 nonce 是否一樣,並回傳 token

login res

main.go
// This is a simple example of how to implement a web3 login endpoint using the siwe-go library.
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/ethereum/go-ethereum/common/hexutil"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/golang-jwt/jwt/v5"
	"github.com/spruceid/siwe-go"
)

const port = ":8080"

func main() {
	http.HandleFunc("/api/v1/auth/web3:login", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
			return
		}
		err := Web3Login(w, r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	})

	err := http.ListenAndServe(port, nil)
	if err != nil {
		fmt.Println("Error starting server:", err)
	}
}

// verifyWalletSignature verifies the signature of a wallet address
func verifyWalletSignature(messageStr string, sig string) (jwt.MapClaims, error) {

	message, err := siwe.ParseMessage(messageStr)
	if err != nil {
		err = fmt.Errorf("parse message err: %v", err)
		return nil, err
	}

	verify, err := message.ValidNow()
	if err != nil {
		err = fmt.Errorf("verify message err: %v", err)
		return nil, err
	}

	if !verify {
		err = fmt.Errorf("verify message fail: %v", err)
		return nil, err
	}

	publicKey, err := message.VerifyEIP191(sig)

	if err != nil {
		err = fmt.Errorf("verifyEIP191 err: %v", err)
		return nil, err
	}

	pubBytes := crypto.FromECDSAPub(publicKey)
	publicKeyString := hexutil.Encode(pubBytes)

	// Return the verified claims
	claims := jwt.MapClaims{
		"web3_pub_key": publicKeyString,
	}
	return claims, nil
}

// LoginRequest represents the login request body
type LoginRequest struct {
	Message   string `json:"message"`
	Signature string `json:"signature"`
}

func Web3Login(w http.ResponseWriter, r *http.Request) error {
	// Parse the login request body
	req := new(LoginRequest)

	// 解析 JSON 請求體
	decoder := json.NewDecoder(r.Body)
	err := decoder.Decode(req)
	if err != nil {
		return errors.New("invalid request body")
	}

	// Verify the signature
	claims, err := verifyWalletSignature(req.Message, req.Signature)
	if err != nil {
		return errors.New(err.Error())
	}

	// Generate a JWT token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
	if err != nil {
		return errors.New("error creating JWT token")
	}

	// Set the JWT token in the response header
	http.SetCookie(w, &http.Cookie{
		Name:    "jwt_token",
		Value:   tokenString,
		Expires: time.Now().Add(time.Hour * 24),
	})

	// Return a success response
	w.WriteHeader(http.StatusOK)
	return nil
}

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104