本章深入解析基于区块链钱包的登录签名机制,系统讲解 EIP-191、EIP-712、SIWE 标准及其在登录流程中的应用,并结合合约钱包(EIP-1271)和 ENS 链上身份,实现安全、结构化的用户认证体系,是 Web3 应用中「从连接到可信登录」的核心一环。
📚 作者:Henry 🧱 系列:《区块链钱包原理与前端集成实践》 · 第 4 篇 👨💻 受众:Web3 开发者 / 区块链学习者\ 👉 系列持续更新中,建议收藏专栏或关注作者
连接钱包 ≠ 登录
登录本质是对钱包地址的所有权确认 + 签名认证 + 状态持久化:
步骤 | 描述 |
---|---|
1️⃣ 连接钱包 | 获取地址 |
2️⃣ 生成消息 | 携带时间戳 + 随机 nonce |
3️⃣ 用户签名 | 通过钱包私钥签署消息 |
4️⃣ 服务端验证 | 校验签名结果与 nonce |
5️⃣ 保存登录态 | 发放 token / session |
personal_sign
)import { BrowserProvider } from 'ethers'
const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const message = `登录 DApp,请签名确认\n时间:${Date.now()}`
const signature = await signer.signMessage(message)
⚠️ 适用于简单认证或 Demo 环境,不推荐用于登录授权生产环境。
eth_signTypedData_v4
)const domain = {
name: 'MyDApp',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
}
const types = {
Login: [
{ name: 'address', type: 'address' },
{ name: 'nonce', type: 'string' },
]
}
const message = {
address: userAddress,
nonce: 'abc123',
}
const signature = await signer.signTypedData(domain, types, message)
统一链上登录格式标准,结合 EIP-4361
消息规范,支持钱包间兼容、服务端验证。
example.com wants you to sign in with your Ethereum account:
0xabc...1234
Sign-In Request at 2025-06-18T15:32:12Z
Nonce: 9xb293
URI: https://example.com
Version: 1
Chain ID: 1
pnpm add siwe
import { SiweMessage } from 'siwe'
const msg = new SiweMessage({
domain: 'example.com',
address,
statement: 'Sign in to MyDApp',
uri: window.location.origin,
version: '1',
chainId: 1,
nonce,
issuedAt: new Date().toISOString(),
})
const prepared = msg.prepareMessage()
const signature = await signer.signMessage(prepared)
.eth
名称import { useEnsName, useEnsAvatar } from 'wagmi'
const { data: ensName } = useEnsName({ address })
const { data: avatar } = useEnsAvatar({ name: ensName })
合约钱包不能使用标准 signMessage,必须调用合约内 isValidSignature() 方法验证。
async function isContractWallet(address: string, provider: JsonRpcProvider) {
const code = await provider.getCode(address)
return code !== '0x'
}
若是合约钱包,使用 EIP-1271:
function isValidSignature(bytes32 hash, bytes signature)
external
view
returns (bytes4 magicValue); // 0x1626ba7e
组件名 | 作用 |
---|---|
useSignatureLogin |
封装签名 + 校验逻辑,暴露登录状态 |
LoginGate |
路由级别鉴权封装(未登录跳转) |
UserIdentityCard |
展示 ENS、合约钱包标识、头像 |
SignaturePrompt |
签名弹窗提示组件(签名中状态反馈) |
useSignatureLogin.ts
import { useCallback, useState } from 'react'
import { BrowserProvider, verifyMessage } from 'ethers'
import { useAccount } from 'wagmi'
export function useSignatureLogin() {
const { address, isConnected } = useAccount()
const [isLoggingIn, setIsLoggingIn] = useState(false)
const [isLoggedIn, setIsLoggedIn] = useState(false)
const login = useCallback(async () => {
if (!isConnected || !window.ethereum) return
setIsLoggingIn(true)
try {
const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const nonce = crypto.randomUUID()
const message = `Sign in to DApp\nAddress: ${address}\nNonce: ${nonce}`
const signature = await signer.signMessage(message)
const isValid = await verifyMessage(message, signature) === address
if (isValid) {
setIsLoggedIn(true)
// 可选:保存 token / session / 发起后端请求
}
} catch (err) {
console.error('签名登录失败:', err)
} finally {
setIsLoggingIn(false)
}
}, [address, isConnected])
return { isLoggedIn, isLoggingIn, login }
}
LoginGate.tsx
(页面级登录权限控制)'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAccount } from 'wagmi'
import { useSignatureLogin } from '@/hooks/useSignatureLogin'
export function LoginGate({ children }: { children: React.ReactNode }) {
const router = useRouter()
const { isConnected } = useAccount()
const { isLoggedIn, login } = useSignatureLogin()
useEffect(() => {
if (!isConnected) return
if (!isLoggedIn) login()
}, [isConnected, isLoggedIn])
if (!isConnected) {
return <p>请先连接钱包</p>
}
if (!isLoggedIn) {
return <p>正在登录中...</p>
}
return <>{children}</>
}
UserIdentityCard.tsx
(展示 ENS 名称、头像、合约钱包状态)tsx
复制编辑
'use client'
import { useAccount, useEnsName, useEnsAvatar } from 'wagmi'
import { useEffect, useState } from 'react'
import { BrowserProvider } from 'ethers'
export async function isContractWallet(address: string) {
const provider = new BrowserProvider(window.ethereum)
const code = await provider.getCode(address)
return code !== '0x'
}
export function UserIdentityCard() {
const { address } = useAccount()
const { data: ensName } = useEnsName({ address })
const { data: avatar } = useEnsAvatar({ name: ensName ?? undefined })
const [isContract, setIsContract] = useState(false)
useEffect(() => {
if (!address) return
isContractWallet(address).then(setIsContract)
}, [address])
return (
<div className="border rounded-xl p-4 space-y-2 w-full max-w-sm">
{avatar && <img src={avatar} alt="ENS Avatar" className="w-12 h-12 rounded-full" />}
<p>地址:{address}</p>
<p>ENS:{ensName ?? '未设置'}</p>
<p>账户类型:{isContract ? '合约钱包' : '普通钱包(EOA)'}</p>
</div>
)
}
SignaturePrompt.tsx
(签名请求时的 UI 状态反馈)'use client'
import { useSignatureLogin } from '@/hooks/useSignatureLogin'
export function SignaturePrompt() {
const { isLoggingIn } = useSignatureLogin()
if (!isLoggingIn) return null
return (
<div className="fixed top-4 right-4 p-3 bg-blue-100 border border-blue-400 rounded-xl text-sm shadow-md">
✍️ 请在钱包中签名以完成登录
</div>
)
}
import { LoginGate } from '@/components/LoginGate'
import { UserIdentityCard } from '@/components/UserIdentityCard'
import { SignaturePrompt } from '@/components/SignaturePrompt'
export default function ProtectedPage() {
return (
<LoginGate>
<SignaturePrompt />
<UserIdentityCard />
{/* ...其余内容 */}
</LoginGate>
)
}
以上代码,全部 基于 React + wagmi v2 + Ethers.js v6 + TypeScript
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!