Web3 连接钱包和钱包签名安全吗?登陆签名认证实战与原理全方位解析

很多朋友对 Web3 网站中连接钱包和签名的操作一直处于云里雾里的状态,不清楚连接钱包和签名到底安不安全?会不会被盗 U?

今天 Noah 就把连接钱包和签名这几件事情彻底讲清楚,并且手把手带你实现一个连接钱包、签名和管理用户权限的实战案例。技术部分会讲解在实际项目中如何去做钱包连接、签名,以及在后端和数据库如何去记录钱包和管理用户,等于是一个全栈内容。

所以本篇内容适合不懂技术的朋友来了解概念,同时也适合懂技术的朋友来学习技术。我相信这会是很多开发 Web3 项目的小伙伴都有的一个刚需。

概念篇

相信钱包这个概念每个玩 Web3 的朋友都已经非常熟悉了,我就简单讲一下吧,拉平大家的认知。

什么是钱包?

Web3 的钱包和支付宝、微信支付这类软件的作用几乎是一样的,用来管理和存储资产。其中有一点细微的区别,也是 Web3 去中心化的基础,那就是 Web3 的钱包是可以存储在本地的,只有你一个人拥有它。而支付宝和微信支付这类软件是存储在阿里和腾讯的服务器里面的,如果它们的服务器出现故障,或者你的账号触犯了它们公司的一些规定,都可能导致你的资产无法被取出。但是 Web3 永远都不会有这种情况,除非有一天全世界的所有矿工集体消失,但我认为未来几十年里应该不会出现这种情况。

去中心化还有一个好处,那就是几乎所有的 DApp 都可以通过一个通用的钱包来连接,不需要每个网站都去注册一个账号。而你在网站中的资产都可以存储到区块链上,几乎没有人能篡改你的数据,除非他有能力同时控制世界上大多数的矿工。

现在大家应该已经知道钱包的作用了,它是实现去中心化的基础和必要的工具之一。

那么钱包到底是什么呢?有人认为 Metamask 这类软件就是钱包,也有人认为安币这类交易所里的账号就是钱包。实际上钱包的本质就是一串文字。你可以把它放到 Metamask 这类钱包软件里,也可以存储到电脑、手机的记事本上,甚至你可以拿一张纸去把它记下来,或者为了绝对安全,你可以用脑子把它记下来。

创建一个 Web3 钱包是非常容易和快速的,现在有很多软件可以帮我们在 1 秒内创建一个钱包,如果你懂代码的话,还可以通过代码批量创建钱包。

钱包的合法性是不需要被任何机构认可的,没有实名认证,没有封号之类的操作。所以一个人可以拥有一万个钱包,或者更多。

私钥、地址和助记词

组成钱包的那一串文字就是私钥。你可以简单把它理解成密码,虽然它们并不完全一样。密码是不可以修改的,一旦遗忘,那么你将永远失去这个钱包。一旦把密码透漏给其他人,那么你的钱包将永远不再安全。正因为 Web3 的钱包完全自己独有,所以要自己去承担对应的风险和损失,这是有利有弊的。

这里面其实存在一个痛点,那就是大家这么多年来已经在 Web2 的模式下熏陶严重,习惯性的让中心化的服务器帮我们记录所有能记录的东西,包括资产。所以很多人很难用一些简单有效的方式记录自己的私钥。有一些公司发现了这个痛点,就研发了一种叫做硬件钱包的产品来帮助用户记录钱包的私钥。

硬件钱包的形式通常是一个 U 盘,或者一个像手机的设备。或者一个从来不联网的手机也可以被称作是硬件钱包。做硬件钱包比较出名的有几家,其中知名度最高的是 Legder。这里插播一个广告,如果你想买硬件钱包可以联系我哈。

对应的软件钱包就是一个网站、一个浏览器插件或者一个 App。这种软件钱包有很多,市面上比较出名的有几十个上百个。其中最老牌、知名度最高的是 Metamask。由于你使用钱包,就等于把身家性命都托付给了软件钱包,所以选择一个靠谱的软件钱包是很有必要的。软件钱包的原则就是不联网、私钥只存储在你的设备本地。

除了私钥之外,钱包还有另外两个概念比较重要,分别是地址和助记词。

比如别人要给你转账,需要知道你的账户,那地址就表示了你的账户。地址是可以通过私钥推导出来的,但是地址无法反向推导私钥。这种不可逆的算法保证了我们可以正常接收和发送资产给其他用户。

助记词和私钥有点像,都可以推导出来地址。但是私钥是一串毫无规律可言的 16 进制字符串,难以记忆,相反助记词更加语义化,容易被人记住。所以从用户角度看,助记词的优势就是为了帮助你记忆账户的,当然技术上两者还是有区别的,但是这个我只讲概念,所以先忽略技术上的差异。助记词的形式就是按照顺序排列的英语单词列表。不同的协议规范生成的助记词不同,比特币和以太坊的账户都是基于 BIP39 规范实现的,一般会从 2048 个单词中生成五种长度的助记词,长度分别是 12、15、18、21 和 24。但是由于长度太长难以记忆,所以最常见的软件钱包一般是 12 个长度的单词,像硬件钱包更多使用的是 24 位。通过记忆这批单词,一样可以达到和私钥完全一致的效果。

以上就是 Web3 钱包的基础概念。

连接钱包和钱包签名到底安不安全?

接下来回到主题,聊聊连接钱包和签名是不是安全的。

首先聊一下连接钱包。这一步的作用仅仅是让网站知道你的地址,通常来说是没关系的。如果你不想让别人知道你的地址,那么网站就无法区别用户了,你也无法正常使用网站。当然你也可以拿一个新的钱包进行连接。如果和 Web2 做一个类比的话,有点像注册。但是每次打开网站都需要连接钱包,所以逻辑上不完全一致。

接下来是签名。签名的作用仅仅是验证你的身份,通常来说也是没关系的。只知道地址并不能验证你真的是你,因为这是可以被冒充的。签名的过程只是通过私钥对一些信息进行加密,然后服务器去解密,对结果进行对比。 不过签名分几类,第一类仅仅是签名,不涉及链上交互,这类是没关系的。 第二类是链上交互,可能会消耗一些 Gas,但是不涉及资产转移,不过你要看清楚合约的内容,有些授权之类的方法一定要谨慎。因为一旦授权给了一些盗 U 的地址,那你的钱就会被别人全部转走。 第三类是交易,也就是发币给其他地址。这种是必须的,你只需要检查好转账的额度和地址正确就可以了。

所以我们的结论就是:连接钱包是安全的,起码不会造成任何资产上的损失。钱包签名是有风险的,需要检查签名的信息是否正确。

技术篇

下面我用代码演示一下如何开发一个带有钱包连接和签名登陆的网站。

项目采用的技术栈如下:

  • 前端框架:Next.js
  • 后端框架:Next.js
  • 数据库:Postgres
  • ORM 框架:Prisma
  • Serverless 平台:Vercel
  • 钱包平台:wallet connect
  • Web3 Hooks 库:wagmi
  • Web3 库:viem
  • 认证框架:NextAuth

这套技术栈可以说是 2024 年最新的一套技术栈了,非常适合大家学习和参考。

项目搭建

首先我们来创建一个 Next.js 项目,运行以下命令:

shell 复制代码
npx create-next-app@latest

然后根据提问选择合适的回答即可。

连接钱包的逻辑

前端连接钱包的操作可以分为以下几个步骤,或者说是状态:

未连接

连接中

选择钱包

连接完成

我们完全可以把这套标准化的状态封装成几个通用的组件,在不同的项目中进行使用。

这些组件从维度上又可以分为两层,下层的逻辑层和上层的 UI 层。

像 ethersjs、viem 这类库属于逻辑层,像 rainbowkit 和 walletconnect 这类库属于 UI 层。wagmi 这种库刚好处于两者之间,属于对逻辑层的进一步封装,但是仍然没有涉及到 UI。

这里我们选择是使用 wallet connect。

创建 wallet connect 项目

好了,现在你已经对连接钱包的基本逻辑和概念有了一个整体上的认识,接下来我们进入实操阶段。

我们使用的是 wallet connect 库,需要在 wallet connect cloud 中创建一个 Project,并且拿到对应的 Project ID。

输入项目名 Project Name,Type 选择 App,Link 填写应用的网址,也可以不填写。

创建完成后可以在控制台中看到 Project ID。

安装钱包依赖

wallet connect 是非常灵活的,它支持 wagmi v1 和 v2、ethers v5 和 v6。

这里我选择使用 wagmi v1。选择 wagmi v1 是有原因的。首先目前的 wagmi 依赖了 viem,viem 是一个比 ethers 更好的抽象,关于 viem 我后面可能会给大家讲。其次不选择 wagmi v2 的原因是 v2 目前仍然是 alpha 版本,没有发布正式版,API 仍然不稳定,所以选择更加稳定可靠的 v1 版本。

使用 wagmi v1 版本需要安装制定版本的依赖:

shell 复制代码
npm install @web3modal/wagmi@3.5.7 wagmi@1.4.13 viem@1.21.4

配置连接钱包

安装完成后,创建 context/Web3Modal.tsx 文件,并写入以下内容:

tsx 复制代码
"use client";

import { createWeb3Modal, defaultWagmiConfig } from "@web3modal/wagmi/react";

import { WagmiConfig } from "wagmi";
import { arbitrum, mainnet } from "viem/chains";

// 1. 设置项目 ID
const projectId = "xxx";

// 2. 创建 wagmi 配置对象
const metadata = {
  name: "xxx",
  description: "xxx",
  url: "https://xxx.com",
  icons: ["https://avatars.githubusercontent.com/u/37784886"],
};

const chains = [mainnet, arbitrum];
const wagmiConfig = defaultWagmiConfig({ chains, projectId, metadata });

// 3. 创建对话框
createWeb3Modal({
  wagmiConfig,
  projectId,
  chains,
  enableAnalytics: true, // Optional - defaults to your Cloud configuration
});

export function Web3Modal({ children }: PropsWithChildren) {
  return <WagmiConfig config={wagmiConfig}>{children}</WagmiConfig>;
}

这个文件主要有三个部分:

  1. 设置项目 ID。
  2. 创建 wagmi 配置对象,可以在这里指定支持哪些链,以及项目的一些配置。
  3. 创建 Web3 对话框。

app/layout.tsx 文件中使用 Web3Modal 包裹着其他内容。

tsx 复制代码
import { Web3Modal } from "../context/Web3Modal";

export default function RootLayout({ children }: PropsWithChildren) {
  return (
    <html lang="en">
      <body>
        <Web3Modal>{children}</Web3Modal>
      </body>
    </html>
  );
}

最后在 page.tsx 中添加连接钱包的按钮。

tsx 复制代码
<w3m-button size="sm" balance="hide"></w3m-button>

需要注意的是,w3m-button 是不需要导入的,它不是一个 React 组件,而是一个 WebComponent。

我再来解释一下两个属性的作用。

  • size 表示按钮的尺寸,sm 是比较小的一个尺寸。
  • balance 表示是否显示余额,hide 表示隐藏。

我们可以在浏览器中看到这个按钮。

可以看到 Wallet Connect 做的非常细致,支持了 380+ 的钱包,几乎涵盖了市面上所有的钱包。

创建数据库

仅仅连接了钱包是不够的,我们还需要在后端进行记录用户的账户。所以接下来我们要搭建数据库。

这里 Noah 选择的平台是 Vercel。

首先你需要创建一个项目,最简单的方式就是把本地的项目推送到 GitHub,然后再用 GitHub 连接 Vercel。这样 Vercel 就可以直接基于 GitHub 的仓库创建项目。

因为考虑到一些人没有基础,我来演示一下具体的操作步骤。

首先在 GitHub 中创建一个仓库。

创建完成后回到本地的项目中,执行几条命令。

  1. 初始化 Git 仓库:git init
  2. 添加所有文件到暂存区:git add .
  3. 提交所有文件:git commit -m "feat: initial"
  4. 关联远程仓库:git remote add origin git@github.com:luzhenqian/siwe-example.git
  5. 推送本地的提交到远程仓库的 main 分支:git push --set-upstream origin main

然后在 Vercel 的控制台中点击 Add New,选择 Project。然后 import 对应的项目。最后点击 Deploy 按钮开始部署。

部署完成后在项目的 Storage 标签中创建一个 PostgreSQL 数据库。

选择数据库的名字和区域。

配置环境,这里可以把三个环境都选上。

然后继续按照官方文档的步骤去操作。

第一步是连接项目,点击 Connect Project,创建完数据库会默认连接好当前项目,并创建好对应的环境变量。

第二步是同步环境变量,在本地项目的控制台里运行:vercel env pull .env.development.local

如果你没有安装过 vercel 的命令行工具的话,需要先安装。

shell 复制代码
npm i -g vercel

安装完成运行 vercel -v 来查看是否安装成功。

然后运行 vercel link 命令来链接项目,这里会有几个简单的选项。

完成以上几步就可以重新运行 pull 命令来同步环境变量了。

同步完成后本地项目中的 .env.development.local 文件会有很多个环境变量。

其中最重要的是这几个:

text 复制代码
POSTGRES_DATABASE="xxx"
POSTGRES_HOST="xxx"
POSTGRES_PASSWORD="xxx"
POSTGRES_PRISMA_URL="xxx"
POSTGRES_URL="xxx"
POSTGRES_URL_NON_POOLING="xxx"
POSTGRES_USER="default"

第三步是安装 SDK。虽然 vercel 官方有一个 @vercel/postgres 的库,它很符合 vercel 的设计原则:简单粗暴。

这虽然在开发早期有一些优势,但实际上对于管理一个复杂的数据库结构来说并不是一个很好的选择。更好的选择是使用一些 ORM 库。这里我选择使用 Prisma。

运行以下命令:

bash 复制代码
# 安装
npm i prisma @prisma/client

# 初始化
npx prisma init

初始化后会在根目录下生成 prisma/schema.prisma 文件。

在 prisma/schema.prisma 文件中添加一个 User 的结构:

text 复制代码
model User {
	id Int @id @default(autoincrement())
	account String @unique
	isActivated Boolean @default(false)
	
	createdAt DateTime @default(now())
	updatedAt DateTime @updatedAt
}

这个语法是 Prisma 描述数据的语言,我就不过多介绍了。如果你有一定的编程经验,应该也可以大致看懂。除了 id、createdAt 和 updatedAt 这三个常规字段外,主要的就是账户 account 和是否授权 isActivated 两个字段。

然后运行 npx prisma migrate dev 在数据库中创建对应的表。

修改 prisma/schema.prisma 文件中的 datasource

txt 复制代码
datasource db {
  provider = "postgresql"
  url = env("POSTGRES_PRISMA_URL") // uses connection pooling
  directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}

还有一点,要修改一下 npm run build 命令,添加 prisma generate & ,用来在 vercel 上面部署时生成对应的数据结构。

到这里,数据库的配置就完成了。

钱包签名登陆后端接口

很多网站都是直接用钱包签名作为登录方式的,我来演示一下该如何实现这个功能。

首先在 Nextjs 框架中创建一个 API,我们可以叫 login/route.ts,然后写入以下逻辑:

ts 复制代码
import { PrismaClient } from "@prisma/client";
import { verifyMessage } from "viem";
import { sign } from "jsonwebtoken";

const prisma = new PrismaClient();

export async function POST(req: Request) {
  try {
    const { address, signature } = await req.json();

    // 校验参数
    if (!address || !signature) {
      return new Response(
        JSON.stringify({
          message: "invalid request",
        }),
        { status: 400 }
      );
    }

    // 验证签名
    const result = await verifyMessage({
      address,
      message: "Hello world!",
      signature,
    });
    
    if (!result) {
      return new Response(JSON.stringify({ message: "Invalid signature" }), { status: 400 });  
    }

    console.debug(result, "result");

    // 创建用户
    const user = await prisma.user.create({
      data: {
        account: address,
      },
    });

    return new Response(
      // 返回 token
      JSON.stringify({
        token: sign(user, "noah666"),
      }),
      { status: 200 }
    );
  } catch (error) {
    console.debug("error", error);
    return new Response(
      JSON.stringify({
        message: "internal server error",
      }),
      { status: 500 }
    );
  }
}

然后我们可以创建一个新的私钥来模拟签名。这个私钥是测试用的,暴露给大家也没关系。

ts 复制代码
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "wagmi";

async function main() {
  const client = createWalletClient({
    chain: mainnet,
    transport: http(),
    account: privateKeyToAccount("0xcaa02952c4ccdc5292a0431fe27b2eb13062eff1b6753ac5310386c61044e0e1"),
  });

  console.debug("account:", client.account);

  const signature = await client.signMessage({
    account: client.account!,
    message: "Hello world!",
  });

  console.debug("signature:", signature);

  const res = await fetch("http://localhost:3000/api/login", {
    method: "POST",
    body: JSON.stringify({
      address: client.account.address,
      signature,
    }),
  });

  const data = await res.json();

  console.debug("data: ", data);
}

main();

然后运行 npx tsx ./test.ts,得到 account 和 signature,然后去 HTTP 调试工具中进行测试。

测试通过。当然实际项目中可能会使用随机数 nonce 作为 message 的一部分,这样可以防止别人拿到 signature 后伪造身份,这里仅仅是演示,我就不增加复杂度了。大家知道这个细节就可以了。

钱包签名登陆前端实现

接下来我们开始编写前端代码。

首先在 layout.tsx 中加入以下代码:

tsx 复制代码
"use client";

import { WagmiConfig, createConfig, configureChains, mainnet } from "wagmi";

import { publicProvider } from "wagmi/providers/public";

import { CoinbaseWalletConnector } from "wagmi/connectors/coinbaseWallet";
import { InjectedConnector } from "wagmi/connectors/injected";
import { MetaMaskConnector } from "wagmi/connectors/metaMask";
import { WalletConnectConnector } from "wagmi/connectors/walletConnect";

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet],
  [publicProvider()]
);

// 设置 wagmi config
const config = createConfig({
  autoConnect: true,
  connectors: [
    new MetaMaskConnector({ chains }),
    new CoinbaseWalletConnector({
      chains,
      options: {
        appName: "wagmi",
      },
    }),
    new WalletConnectConnector({
      chains,
      options: {
        projectId: "...",
      },
    }),
    new InjectedConnector({
      chains,
      options: {
        name: "Injected",
        shimDisconnect: true,
      },
    }),
  ],
  publicClient,
  webSocketPublicClient,
});

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
	  <WagmiConfig config={config}>
		// ...
	  </WagmiConfig>
  );
}

我们必须在最外层注入 Wagmi 的 Context 才可以使用它提供的 Hooks。同时要删除掉导出的 metadata,因为它们只能在服务端工作。

还有要设置好原来在 wallet connect 上申请的 project id。

然后在 page.tsx 中增加一段签名的代码。

tsx 复制代码
"use client";

import Image from "next/image";
import { useEffect } from "react";
import { useAccount } from "wagmi";
import { signMessage } from "wagmi/actions";

export default function Home() {
  // 获取钱包
  const account = useAccount();

  useEffect(() => {
	// 如果没有连接钱包,就不需要签名
    if (!account) return;
    (async () => {
      // 如果已经有了 token,认为已经签过名验证过了
      const token = localStorage.getItem("token");
      if (!token) {
        // 签名
        const signature = await signMessage({
          message: "Hello world!",
        });
        // 调用登陆接口
        const res = await fetch("/api/login", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            address: account?.address,
            signature,
          }),
        });
        // 登陆成功,存储 token
        if (res.ok) {
          const resData = await res.json();
          localStorage.setItem("token", resData.token);
        }
      }
    })();
  }, [account]);

  return (...)
}

现在我们连接钱包后,会自动弹出签名的弹窗。

点击签名就可以登陆了。

以上就是一个非常简单的签名登陆的逻辑。

SIWE 和 EIP-4361

虽然 Web3 讲究去中心化,但是现实中更多人使用的 App 和网站还是中心化的。而在这些中心化的网站中,登录的方式往往都不支持使用 Web3 钱包进行登陆。相反,它们更喜欢集成苹果、谷歌这种中心化的巨头服务商账户作为通用的账户。

为了改变这一点,以太坊提出了 SIWE 的概念,SIWE 是 Sign-in With Ethereum 的缩写,也就是用以太坊登陆的意思。它主要目的是为了让 Web2 中的传统应用可以快速集成以太坊账户的登陆,这样既可以便利 Web3 的用户,还可以从 Web2 中获得获得用户,实现引流。

针对这个目标,以太坊社区提出了 EIP-4361 协议。这个协议描述了该如何实现这个功能。

简单来说 EIP-4361 就是制定了一个特定格式的签名消息,必须按照这个格式让用户签名。

以下就是一个签名消息的示例:

txt 复制代码
service.org wants you to sign in with your Ethereum account:
0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2

I accept the ServiceOrg Terms of Service: https://service.org/tos

URI: https://service.org/login
Version: 1
Chain ID: 1
Nonce: 32891756
Issued At: 2021-09-30T16:25:24Z
Resources:
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
- https://example.com/my-web2-claim.json

这是对应的模板,其中 ${} 包括的是可变的部分:

txt 复制代码
${domain} wants you to sign in with your Ethereum account:
${address}

${statement}

URI: ${uri}
Version: ${version}
Chain ID: ${chain-id}
Nonce: ${nonce}
Issued At: ${issued-at}
Expiration Time: ${expiration-time}
Not Before: ${not-before}
Request ID: ${request-id}
Resources:
- ${resources[0]}
- ${resources[1]}
...
- ${resources[n]}

我给大家解释一下这几个字段具体的含义。

  • domain 是域名。
  • address 是以太坊钱包地址。
  • statement 是可选的,主要是给人看的消息,不能包含换行符。
  • uri 是签名的资源,必须是标准的 URI 格式。
  • version 是当前的版本。
  • chain-id 是所使用的网络。
  • nonce 是用来防止重放攻击的随机令牌,至少应该由 8 位以上的字符数组成。
  • issued-at 是可选的,表示当前时间。
  • expiration-time 是可选的,表示签名的验证消息的过期时间。
  • request-id 是可选的,表示唯一的请求 id。
  • resources 是可选的,表示用户希望作为以来坊身份验证的一部分解析的信息或者信息引用的列表。

以上就是 EIP-4361 主要的消息定义,当然通常不需要我们手动去构建这个签名消息,我们可以借助一些成熟的框架和库来快速使用 SIWE 的功能。接下来我会给大家演示。

NextAuth 权限控制

实现了钱包连接、签名和用户体系后,还需要对用户进行验证。

首先安装以下依赖:

bash 复制代码
npm i @web3modal/siwe@^3.5.7 siwe ethers next-auth

注意一定选择 3.5.7 版本,因为 4.x 版本存在 Bug,不够稳定。

安装完成之后创建 /api/auth/[...nextauth]/route.ts 文件,并在其中添加以下代码。

ts 复制代码
import type { SIWESession } from "@web3modal/core";
import type { NextApiRequest, NextApiResponse } from "next";
import nextAuth from "next-auth";
import credentialsProvider from "next-auth/providers/credentials";
import { SiweMessage } from "siwe";
import { ethers } from "ethers";
import { cookies } from "next/headers";
import { NextRequest } from "next/server";
import { PrismaClient } from "@prisma/client/extension";

declare module "next-auth" {
  interface Session extends SIWESession {
    address: string;
    chainId: number;
  }
}

const prisma = new PrismaClient();

/*
 * For more information on each option (and a full list of options) go to
 * https://next-auth.js.org/configuration/options
 */
async function auth(req: NextRequest | Request, res: NextApiResponse) {
  const nextAuthSecret = process.env["NEXTAUTH_SECRET"];
  if (!nextAuthSecret) {
    throw new Error("NEXTAUTH_SECRET is not set");
  }
  // Get your projectId on https://cloud.walletconnect.com
  const projectId = process.env["NEXT_PUBLIC_PROJECT_ID"];
  if (!projectId) {
    throw new Error("NEXT_PUBLIC_PROJECT_ID is not set");
  }

  const providers = [
    credentialsProvider({
      name: "Ethereum",
      credentials: {
        message: {
          label: "Message",
          type: "text",
          placeholder: "0x0",
        },
        signature: {
          label: "Signature",
          type: "text",
          placeholder: "0x0",
        },
      },
      async authorize(credentials) {
        try {
          if (!credentials?.message) {
            throw new Error("SiweMessage is undefined");
          }
          const siwe = new SiweMessage(credentials.message);
          const provider = new ethers.JsonRpcProvider(
            `https://rpc.walletconnect.com/v1?chainId=eip155:${siwe.chainId}&projectId=${projectId}`
          );

          const nonce = cookies()
            .get("next-auth.csrf-token")
            ?.value.split("|")[0];

          const result = await siwe.verify(
            {
              signature: credentials?.signature || "",
              nonce,
            },
            { provider }
          );

          if (!result.success) {
            throw new Error("Failed to verify message");
          }

          const user = await prisma.user.findUnique({
            where: {
              account: siwe.address,
            },
          });

          if (!user) {
            await prisma.user.create({
              data: {
                ipAddress: (req.headers.get("x-forwarded-for") || "") as string,
                account: siwe.address,
              },
            });
          }

          return {
            id: `eip155:${siwe.chainId}:${siwe.address}`,
          };
        } catch (e) {
          console.error(e);
          return null;
        }
      },
    }),
  ];

  const isDefaultSigninPage =
    req.method === "GET" &&
    (req as unknown as NextApiRequest).query?.["nextauth"]?.includes("signin");

  // Hide Sign-In with Ethereum from default sign page
  if (isDefaultSigninPage) {
    providers.pop();
  }

  return await nextAuth(req as unknown as NextApiRequest, res, {
    // https://next-auth.js.org/configuration/providers/oauth
    secret: nextAuthSecret,
    providers,
    session: {
      strategy: "jwt",
    },
    callbacks: {
      async session({ session, token }) {
        if (!token.sub) {
          return session;
        }

        const [, chainId, address] = token.sub.split(":");

        const user = await prisma.user.findUnique({
          where: {
            account: address,
          },
        });

        if (chainId && address) {
          session.address = address;
          session.chainId = parseInt(chainId, 10);
          (session.user as any) = user;
        }

        return session;
      },
    },
  });
}

export { auth as GET, auth as POST };

.env 文件中配置关键的变量。

txt 复制代码
NEXTAUTH_URL=http://localhost:3003
NEXTAUTH_SECRET=xxxxxx
NEXT_PUBLIC_PROJECT_ID=xxxxxx

然后配置 SIWE 客户端,创建 context/SIWEConfig.ts 文件,并写入以下内容:

ts 复制代码
import { SiweMessage } from "siwe";
import { createSIWEConfig } from "@web3modal/siwe";
import { getCsrfToken, signIn, signOut, getSession } from "next-auth/react";
import type {
  SIWECreateMessageArgs,
  SIWESession,
  SIWEVerifyMessageArgs,
} from "@web3modal/core";

export const siweConfig = createSIWEConfig({
  createMessage: ({ nonce, address, chainId }: SIWECreateMessageArgs) =>
    new SiweMessage({
      version: "1",
      domain: window.location.host,
      uri: window.location.origin,
      address,
      chainId,
      nonce,
      // Human-readable ASCII assertion that the user will sign, and it must not contain `\n`.
      statement: "xxx",
    }).prepareMessage(),
  getNonce: async () => {
    const nonce = await getCsrfToken();
    if (!nonce) {
      throw new Error("Failed to get nonce!");
    }

    return nonce;
  },
  getSession: async () => {
    const session = await getSession();
    if (!session) {
      throw new Error("Failed to get session!");
    }

    const { address, chainId } = session as unknown as SIWESession;

    return { address, chainId };
  },
  verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => {
    try {
      const success = await signIn("credentials", {
        message,
        redirect: false,
        signature,
        callbackUrl: "/protected",
      });

      return Boolean(success?.ok);
    } catch (error) {
      return false;
    }
  },
  signOut: async () => {
    try {
      await signOut({
        redirect: false,
      });

      return true;
    } catch (error) {
      return false;
    }
  },
});

Web3Modal.tsx 文件中把 SIEW 的配置加进来。

tsx 复制代码
// ...
import { siweConfig } from "./SIWEClient";

createWeb3Modal({
  siweConfig
  // ...
});

最后在 page.tsx 中移除原来的签名逻辑。重启服务。

这时候页面会弹出要求签名的弹窗。

点击 Sign 就会跳出来登陆的窗口。

它首先会调用 /api/auth/csrf 接口,会得到一个随机数 Nonce

然后会调用一个 /api/auth/callback/credentials 接口,这里会把签名消息按照协议规定的格式传递给后端。验证成功后会把信息存储到 cookie 中。

最后调用 /api/auth/session 接口,获取用户信息。

然后增加权限拦截。在 components/Auth.tsx 文件中添加以下内容:

tsx 复制代码
"use client";

import React, { useEffect, useState } from "react";
import { getSession } from "next-auth/react";
import { PropsWithChildren } from "react";

export function Authenticated({ children }: PropsWithChildren) {
  const [session, setSession] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function checkSession() {
      const session = await getSession();
      setSession(session);
      setLoading(false);
    }

    checkSession();
  }, []);

  if (loading) {
    return <div>Loading...</div>;
  } else if (!session || !session.user) {
    return (
      <div className="flex flex-col items-center justify-center w-full h-full px-4 py-8 bg-gray-200 rounded-sm dark:bg-gray-800 dark:text-gray-200">
        <h1 className="mb-4 text-3xl font-bold">无权限</h1>
      </div>
    );
  } else {
    return children;
  }
}

把需要保护的内容用 Authenticated 包裹就可以实现权限的控制了。


Noah 是「人人都会 Web3 社群」和「bc1 社区」的发起人和组织者,目前社群规模有几千人,聚集了 Web3 行业中的精英人才,旨在帮助大家在 Web3 的世界里少走弯路,避免成为 Web3 世界里的小韭菜,而是成为割韭菜的锋利镰刀。如果你对 Web3 技术感兴趣,想寻找一份远程工作,或者只是想在 Web3 的世界里撸毛赚钱,欢迎添加 Noah 的微信:LZQ20130415,进群交流学习。

相关推荐
会发光的猪。4 分钟前
vue中el-select选择框带搜索和输入,根据用户输入的值显示下拉列表
前端·javascript·vue.js·elementui
旺旺大力包21 分钟前
【 Git 】git 的安装和使用
前端·笔记·git
雪落满地香37 分钟前
前端:改变鼠标点击物体的颜色
前端
kirito学长-Java1 小时前
springboot/ssm网上宠物店系统Java代码编写web宠物用品商城项目
java·spring boot·后端
海绵波波1071 小时前
flask后端开发(9):ORM模型外键+迁移ORM模型
后端·python·flask
余生H1 小时前
前端Python应用指南(二)深入Flask:理解Flask的应用结构与模块化设计
前端·后端·python·flask·全栈
outstanding木槿1 小时前
JS中for循环里的ajax请求不数据
前端·javascript·react.js·ajax
酥饼~1 小时前
html固定头和第一列简单例子
前端·javascript·html
一只不会编程的猫1 小时前
高德地图自定义折线矢量图形
前端·vue.js·vue
m0_748250931 小时前
html 通用错误页面
前端·html