以太坊登录(SIWE)

Better Auth 的以太坊登录插件

以太坊登录(Sign in with Ethereum,SIWE)插件允许用户使用其以太坊钱包按照 ERC-4361 标准进行身份验证。此插件提供灵活性,支持你自行实现消息验证与 nonce 生成逻辑。

安装

添加服务器插件

将 SIWE 插件添加到你的认证配置中:

auth.ts
import { betterAuth } from "better-auth";
import { siwe } from "better-auth/plugins"; 

export const auth = betterAuth({
    plugins: [
        siwe({
            domain: "example.com",
            emailDomainName: "example.com", // 可选
            anonymous: false, // 可选,默认为 true
            getNonce: async () => {
                // Return an ERC-4361 nonce: 8-250 alphanumeric characters
                return "A1b2C3d4E5f6G7h8J";
            },
            verifyMessage: async (args) => {
                // 在这里实现你的 SIWE 消息验证逻辑
                // 这应该验证签名是否与消息匹配
                return true; // 如果签名有效则返回 true
            },
            ensLookup: async (args) => {
                // 可选:实现 ENS 查询以获取用户名和头像
                return {
                    name: "user.eth",
                    avatar: "https://example.com/avatar.png"
                };
            },
        }),
    ],
});

迁移数据库

运行迁移或生成模式以向数据库添加必要的字段和表。

npx auth migrate

请参阅 Schema 部分以手动添加字段。

添加客户端插件

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { siweClient } from "better-auth/client/plugins"; 

export const authClient = createAuthClient({
plugins: [
    siweClient() 
]
});

使用方法

生成 Nonce

在要求钱包签名之前,为此次登录尝试签发一个 nonce。Nonce 不会与钱包地址或 Chain ID 绑定,因为单步钱包流程可能要等到钱包签署 SIWE 消息后才知道这两个值。

import { authClient } from "@/lib/auth-client";

const { data, error } = await authClient.siwe.nonce();

if (data) {
  console.log("Nonce:", data.nonce);
}

以太坊登录

生成 nonce 并创建 SIWE 消息后,验证签名以完成身份认证:

import { authClient } from "@/lib/auth-client";

const { data, error } = await authClient.siwe.verify({
  message: "Your ERC-4361 SIWE message string",
  signature: "0x...", // The signature from the user's wallet
  email: "[email protected]", // optional, required if anonymous is false
});

if (data) {
  console.log("认证成功:", data.user);
}

message 必须是有效的 ERC-4361 消息(每个标准 SIWE 客户端都会生成此类消息)。在接受签名之前,插件会解析消息,使用匹配的服务器签发的 nonce,从已签名的消息中推导出 addressChain ID,要求已签名的 domain 与你配置的 domain 匹配,并遵守消息的 Expiration TimeNot Before 限制。仅恢复签名并不足够——这种绑定可确保签名只有在与其生成所针对的消息以及当前服务器签发的 nonce 一起提供时才会被接受。如果任何已签名字段无效,验证将失败并返回 401(UNAUTHORIZED_SIWE_MESSAGE_MISMATCH)。

SIWE 签名只能证明对钱包的控制权,而不能证明你传入的 email 的所有权。插件会将该电子邮件按未验证状态存储,并且仅在它尚未被使用时将其绑定到新账户。当 anonymousfalse 且所提供的电子邮件已属于另一个账户时,新钱包账户会改为使用基于钱包派生的地址创建,因此一次登录无法附加另一个账户已拥有的电子邮件。

特定 Chain 的消息

Chain 的选择应包含在 ERC-4361 消息中,而不是验证请求正文中。生成 nonce,使用钱包地址和目标 Chain ID 构建 SIWE 消息,要求钱包对该确切消息进行签名,然后验证已签名的消息:

import { authClient } from "@/lib/auth-client";

const nonce = await authClient.siwe.nonce();

const { data, error } = await authClient.siwe.verify({
  // The signed ERC-4361 message contains the wallet address,
  // Chain ID: 137, and Nonce: nonce.data?.nonce.
  message,
  signature,
});

已签名的 SIWE 消息必须包含一个正数 Chain ID。验证会从该已签名的 Chain ID 中推导钱包身份;如果消息缺少有效的 Chain ID,验证将失败并返回 401 错误。

配置选项

服务器配置

SIWE 插件接受以下配置选项:

  • domain:你的应用程序的域名(生成 SIWE 消息时必需)
  • emailDomainName:不使用匿名模式时用于创建用户账户的电子邮件域名。默认为基础 URL 中的域名
  • anonymous:是否允许无需电子邮件的匿名登录。默认为 true
  • getNonce:为每次登录尝试生成全局唯一 nonce 的函数。你必须实现此函数,使其返回符合 ERC-4361 要求且经过加密安全处理的 nonce:由 8-250 个字母数字字符组成。必须返回 Promise<string>
  • verifyMessage:验证 SIWE 消息签名的函数。它只需要针对所提供的地址执行签名恢复(例如 viem 的 verifyMessage),并返回 Promise<boolean>——插件会在创建会话之前独立验证消息的 nonce、domain、地址、Chain ID 和时间限制
  • ensLookup:用于查询 Ethereum 地址对应的 ENS 名称和头像的可选函数

客户端配置

SIWE 客户端插件不需要任何配置选项:

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { siweClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [
    siweClient(),
  ],
});

数据表结构

SIWE 插件新增了一个 walletAddress 表,用于存储用户钱包关联信息:

字段类型描述
idstring主键
userIdstring关联用户的 user.id
addressstring以太坊钱包地址
chainIdnumber链 ID(例如以太坊主网为 1)
isPrimaryboolean是否为用户的主钱包
createdAtdate创建时间戳

示例实现

下面是一个完整示例,展示如何实现 SIWE 认证:

auth.ts
import { betterAuth } from "better-auth";
import { siwe } from "better-auth/plugins";
import { generateRandomString } from "better-auth/crypto";
import { verifyMessage, createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";

export const auth = betterAuth({
  database: {
    // 你的数据库配置
  },
  plugins: [
    siwe({
      domain: "myapp.com",
      emailDomainName: "myapp.com",
      anonymous: false,
      getNonce: async () => {
        // 生成加密安全的随机 nonce
        return generateRandomString(32, "a-z", "A-Z", "0-9");
      },
      verifyMessage: async ({ message, signature, address }) => {
        try {
          // 使用 viem 库验证签名(推荐)
          const isValid = await verifyMessage({
            address: address as `0x${string}`,
            message,
            signature: signature as `0x${string}`,
          });
          return isValid;
        } catch (error) {
          console.error("SIWE 验证失败:", error);
          return false;
        }
      },
      ensLookup: async ({ walletAddress }) => {
        try {
          // 可选:使用 viem 查询 ENS 名称和头像
          const client = createPublicClient({
            chain: mainnet,
            transport: http(),
          });

          const ensName = await client.getEnsName({
            address: walletAddress as `0x${string}`,
          });

          const ensAvatar = ensName
            ? await client.getEnsAvatar({
                name: ensName,
              })
            : null;

          return {
            name: ensName || walletAddress,
            avatar: ensAvatar || "",
          };
        } catch {
          return {
            name: walletAddress,
            avatar: "",
          };
        }
      },
    }),
  ],
});