别着急,坐和放宽
使用社交账号登录
技术栈:
什么是Nonce?
Nonce(Number used ONCE)是一次性随机数,每次登录都不同。
为什么需要Nonce?
关键安全措施:每次请求都生成新nonce
为什么每次都要刷新?
验证签名成功后,后端返回JWT token用于后续请求认证。
JWT结构:
使用方式:
优势:
JWT详细原理见第一篇文章,这里不再展开。
创建wagmi配置:
环境变量:
在根布局中使用:
连接按钮组件:
ConnectKit的优势:
API调用封装:
登录按钮组件:
关键点:
useSignMessage Hook自动处理签名signMessageAsync 自动添加EIP-191前缀(与后端 accounts.TextHash 匹配)用户信息组件:
获取Nonce接口:
登录验证接口:
// 场景1: 没有nonce(危险)
const message = "Sign to login"; // 消息固定不变
const signature = await signMessage(message); // 签名也固定
// 问题:黑客截获signature后可以无限次重放登录
POST /api/login { address, signature } // ✅ 成功
POST /api/login { address, signature } // ✅ 又成功了!(重放攻击)
// 场景2: 使用nonce(安全)
const nonce = await getNonce(address); // 每次都不同
const message = `Sign to login with nonce: ${nonce}`;
const signature = await signMessage(message); // 签名基于新nonce
// 结果:每个签名只能使用一次
POST /api/login { address, signature } // ✅ 成功
POST /api/login { address, signature } // ❌ 失败(nonce已刷新)
// ✅ 正确做法(重要!)
func GetNonce(address string) string {
// 总是生成新nonce
newNonce := generateRandomNonce()
// 更新数据库
user.Nonce = newNonce
db.Save(&user)
return newNonce
}
// 登录成功后再次刷新(双重保护)
func Login(address, signature string) {
if verifySignature() {
user.Nonce = generateRandomNonce() // 再次刷新
db.Save(&user)
}
}
攻击场景:用户取消签名
1. 用户点击登录 → 前端获取nonce1
2. 看到钱包弹窗 → 点击"拒绝"
3. 攻击者通过中间人攻击截获了nonce1
4. 用户再次点击登录 → 前端获取nonce2(新的)
5. 结果:攻击者的nonce1已失效,无法使用 ✅
如果不刷新:
4. 用户再次点击登录 → 返回nonce1(旧的)
5. 结果:攻击者可以用nonce1构造签名并登录 ❌
JWT = Header.Payload.Signature
示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3YWxsZXRfYWRkcmVzcyI6IjB4MTIzLi4uIiwiZXhwIjoxNjcyNTMxMjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
解码后:
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"wallet_address": "0x123...", "exp": 1672531200}
Signature: HMACSHA256(base64(header) + "." + base64(payload), secret)
// 前端保存token
localStorage.setItem('auth_token', token);
// 后续请求携带token
fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
frontend/
├── app/
│ ├── layout.tsx # 根布局
│ ├── page.tsx # 主页面
│ └── providers.tsx # wagmi配置Provider
├── components/
│ ├── ConnectButton.tsx # 钱包连接按钮
│ ├── LoginButton.tsx # 签名登录按钮
│ └── UserProfile.tsx # 用户信息显示
├── hooks/
│ └── useAuth.ts # 认证状态管理
├── lib/
│ └── api.ts # API调用封装
└── package.json
npm install wagmi viem @tanstack/react-query
npm install @rainbow-me/rainbowkit # 可选:美观的钱包连接UI
// app/providers.tsx
'use client';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectKitProvider, getDefaultConfig } from 'connectkit';
// Create wagmi config
const config = createConfig(
getDefaultConfig({
// Your dApp info
appName: 'Wallet Login Demo',
appDescription: 'Web3 Authentication Demo',
appUrl: 'https://your-domain.com',
appIcon: 'https://your-domain.com/logo.png',
// WalletConnect project ID (get from https://cloud.walletconnect.com)
walletConnectProjectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
// Supported chains
chains: [mainnet, sepolia],
// RPC providers
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(),
},
})
);
// React Query client
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<ConnectKitProvider>{children}</ConnectKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
# .env.local
NEXT_PUBLIC_WC_PROJECT_ID=your_walletconnect_project_id
NEXT_PUBLIC_API_URL=http://localhost:8080
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// components/ConnectButton.tsx
'use client';
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { ConnectKitButton } from 'connectkit';
export default function ConnectButton() {
// wagmi提供的Hooks
const { address, isConnected } = useAccount();
const { disconnect } = useDisconnect();
return (
<div>
{isConnected ? (
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{address?.slice(0, 6)}...{address?.slice(-4)}
</span>
<button
onClick={() => disconnect()}
className="px-4 py-2 bg-red-500 text-white rounded-lg"
>
断开连接
</button>
</div>
) : (
// ConnectKit提供的美观连接按钮
<ConnectKitButton />
)}
</div>
);
}
// lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
// Get nonce for address
export async function getNonce(address: string): Promise<string> {
const response = await fetch(`${API_URL}/api/auth/nonce?address=${address}`);
if (!response.ok) throw new Error('Failed to get nonce');
const data = await response.json();
return data.nonce;
}
// Login with signature
export async function login(address: string, signature: string) {
const response = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, signature }),
});
if (!response.ok) throw new Error('Login failed');
return response.json();
}
// Get user profile (requires token)
export async function getUserProfile(token: string) {
const response = await fetch(`${API_URL}/api/user/profile`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!response.ok) throw new Error('Failed to get profile');
return response.json();
}
// components/LoginButton.tsx
'use client';
import { useState } from 'react';
import { useAccount, useSignMessage } from 'wagmi';
import { getNonce, login } from '@/lib/api';
export default function LoginButton({ onLoginSuccess }: {
onLoginSuccess: (token: string) => void
}) {
const { address } = useAccount();
const { signMessageAsync } = useSignMessage(); // wagmi签名Hook
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!address) {
alert('请先连接钱包');
return;
}
try {
setLoading(true);
// Step 1: 获取nonce
console.log('1. Fetching nonce...');
const nonce = await getNonce(address);
console.log('Nonce:', nonce);
// Step 2: 构造消息
const message = `Sign this message to login with nonce: ${nonce}`;
console.log('2. Message to sign:', message);
// Step 3: 请求签名
// signMessageAsync自动添加EIP-191前缀
console.log('3. Requesting signature...');
const signature = await signMessageAsync({ message });
console.log('Signature:', signature);
// Step 4: 发送到后端验证
console.log('4. Verifying signature...');
const response = await login(address, signature);
console.log('Login success:', response);
// Step 5: 保存token
localStorage.setItem('auth_token', response.token);
// Step 6: 通知父组件
onLoginSuccess(response.token);
} catch (error) {
console.error('Login failed:', error);
alert('登录失败,请重试');
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleLogin}
disabled={!address || loading}
className={`px-6 py-3 rounded-lg text-white font-medium ${
!address || loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700'
}`}
>
{loading ? '签名中...' : '签名登录'}
</button>
);
}
// app/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useAccount } from 'wagmi';
import ConnectButton from '@/components/ConnectButton';
import LoginButton from '@/components/LoginButton';
import UserProfile from '@/components/UserProfile';
export default function Home() {
const { isConnected } = useAccount();
const [token, setToken] = useState<string | null>(null);
// 检查本地存储的token
useEffect(() => {
const savedToken = localStorage.getItem('auth_token');
if (savedToken) {
setToken(savedToken);
}
}, []);
// 登出
const handleLogout = () => {
localStorage.removeItem('auth_token');
setToken(null);
};
return (
<main className="min-h-screen p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Web3 钱包登录</h1>
{/* Step 1: 连接钱包 */}
<div className="mb-6">
<h2 className="text-xl mb-4">步骤1: 连接钱包</h2>
<ConnectButton />
</div>
{/* Step 2: 签名登录 */}
{isConnected && !token && (
<div className="mb-6">
<h2 className="text-xl mb-4">步骤2: 签名登录</h2>
<LoginButton onLoginSuccess={setToken} />
</div>
)}
{/* Step 3: 显示用户信息 */}
{token && (
<div>
<h2 className="text-xl mb-4">用户信息</h2>
<UserProfile token={token} onLogout={handleLogout} />
</div>
)}
</div>
</main>
);
}
// components/UserProfile.tsx
'use client';
import { useEffect, useState } from 'react';
import { getUserProfile } from '@/lib/api';
export default function UserProfile({
token,
onLogout
}: {
token: string;
onLogout: () => void;
}) {
const [profile, setProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getUserProfile(token)
.then(setProfile)
.catch(console.error)
.finally(() => setLoading(false));
}, [token]);
if (loading) return <div>加载中...</div>;
if (!profile) return <div>加载失败</div>;
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label className="text-sm text-gray-600">钱包地址</label>
<div className="font-mono">{profile.wallet_address}</div>
</div>
<div className="mb-4">
<label className="text-sm text-gray-600">用户ID</label>
<div>{profile.id}</div>
</div>
<div className="mb-4">
<label className="text-sm text-gray-600">注册时间</label>
<div>{new Date(profile.created_at).toLocaleString()}</div>
</div>
<button
onClick={onLogout}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
退出登录
</button>
</div>
);
}
backend/
├── main.go # 主程序入口
├── handlers/
│ └── auth.go # 认证处理器
├── middleware/
│ └── jwt.go # JWT中间件
├── models/
│ └── user.go # 用户模型
├── util/
│ └── crypto.go # 签名验证(你提供的方法)
├── database/
│ └── db.go # 数据库初始化
├── .env # 环境变量
└── go.mod
go get github.com/gin-gonic/gin
go get github.com/ethereum/go-ethereum
go get github.com/golang-jwt/jwt/v5
go get gorm.io/gorm
go get gorm.io/driver/sqlite
go get go.uber.org/zap
// models/user.go
package models
import "time"
type User struct {
ID uint `gorm:"primarykey" json:"id"`
WalletAddress string `gorm:"uniqueIndex;not null" json:"wallet_address"`
Nonce string `gorm:"not null" json:"nonce"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// util/crypto.go
package util
import (
"crypto/rand"
"strings"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"go.uber.org/zap"
)
// VerifySignature verifies EIP-191 signature using official go-ethereum method
// This matches wagmi's signMessage which automatically adds EIP-191 prefix
func VerifySignature(walletAddress, msg, sigHex string) bool {
// Decode signature
sig, err := hexutil.Decode(sigHex)
if err != nil {
zap.S().Errorf("invalid sig ('%s'), %w", sigHex, zap.Error(err))
return false
}
// accounts.TextHash automatically adds EIP-191 prefix:
// "\x19Ethereum Signed Message:\n" + len(msg) + msg
msgHash := accounts.TextHash([]byte(msg))
// Adjust recovery ID (v value: 27/28 -> 0/1)
if sig[crypto.RecoveryIDOffset] == 27 || sig[crypto.RecoveryIDOffset] == 28 {
sig[crypto.RecoveryIDOffset] -= 27
}
// Recover public key from signature
pk, err := crypto.SigToPub(msgHash, sig)
if err != nil {
zap.S().Errorf("failed recover public key from sig ('%s'), %w", sigHex, zap.Error(err))
return false
}
// Derive address from public key and compare
recoveredAddr := crypto.PubkeyToAddress(*pk)
return strings.EqualFold(walletAddress, recoveredAddr.Hex())
}
// GenerateRandomNonce generates cryptographically secure random nonce
func GenerateRandomNonce() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hexutil.Encode(b), nil
}
// handlers/auth.go
package handlers
import (
"net/http"
"regexp"
"github.com/gin-gonic/gin"
"your-project/database"
"your-project/models"
"your-project/util"
)
// GetNonce returns nonce for wallet address
// CRITICAL: Always generate new nonce to prevent replay attacks
func GetNonce(c *gin.Context) {
address := c.Query("address")
if address == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "address required"})
return
}
// Validate address format (0x + 40 hex chars)
matched, _ := regexp.MatchString("^0x[0-9a-fA-F]{40}$", address)
if !matched {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid address format"})
return
}
// CRITICAL: Always generate new nonce (防止取消签名后nonce被截获)
nonce, err := util.GenerateRandomNonce()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce"})
return
}
// Find or create user
var user models.User
result := database.DB.Where("wallet_address = ?", address).First(&user)
if result.Error != nil {
// User doesn't exist, create new
user = models.User{
WalletAddress: address,
Nonce: nonce,
}
database.DB.Create(&user)
} else {
// User exists, update nonce
user.Nonce = nonce
database.DB.Save(&user)
}
c.JSON(http.StatusOK, gin.H{"nonce": user.Nonce})
}
// handlers/auth.go (continued)
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"your-project/database"
"your-project/models"
"your-project/util"
)
type LoginRequest struct {
Address string `json:"address" binding:"required"`
Signature string `json:"signature" binding:"required"`
}
// Login verifies signature and returns JWT token
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
// Find user and nonce
var user models.User
result := database.DB.Where("wallet_address = ?", req.Address).First(&user)
if result.Error != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
// Construct message (must match frontend)
message := fmt.Sprintf("Sign this message to login with nonce: %s", user.Nonce)
// Verify signature using official method
valid := util.VerifySignature(req.Address, message, req.Signature)
if !valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
return
}
// Refresh nonce after successful login (prevent replay)
newNonce, _ := util.GenerateRandomNonce()
user.Nonce = newNonce
database.DB.Save(&user)
// Generate JWT token
token, err := util.GenerateJWT(req.Address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"address": req.Address,
})
}
// GetProfile returns user profile (requires JWT)
func GetProfile(c *gin.Context) {
// Get wallet address from JWT middleware
walletAddress, exists := c.Get("wallet_address")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
// Find user
var user models.User
result := database.DB.Where("wallet_address = ?", walletAddress).First(&user)
if result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
// util/jwt.go
package util
import (
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
WalletAddress string `json:"wallet_address"`
jwt.RegisteredClaims
}
// GenerateJWT creates JWT token
func GenerateJWT(walletAddress string) (string, error) {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
return "", fmt.Errorf("JWT_SECRET not set")
}
claims := Claims{
WalletAddress: walletAddress,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// ValidateJWT validates token and returns claims
func ValidateJWT(tokenString string) (*Claims, error) {
secret := os.Getenv("JWT_SECRET")
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// middleware/jwt.go
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"your-project/util"
)
// JWTAuth validates JWT token from Authorization header
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// Get Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization required"})
c.Abort()
return
}
// Parse Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token format"})
c.Abort()
return
}
// Validate JWT
claims, err := util.ValidateJWT(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
// Store wallet address in context for handlers
c.Set("wallet_address", claims.WalletAddress)
c.Next()
}
}