引言
用户认证是任何应用的基础功能,但在去中心化应用(DApp)世界中,传统的用户名密码认证模式并不适用。DApp依赖区块链钱包作为身份标识,用户通过签名消息来证明身份。这种认证方式带来了新的安全考量,也提出了独特的用户体验挑战。
本文将系统讲解DApp用户认证与会话管理的完整方案,从基础的钱包连接,到高级的多因素认证和会话管理,帮助你构建既安全又流畅的用户认证体验。

一、DApp认证基础概念
1.1 区块链身份与钱包
在DApp中,用户的区块链地址就是其身份标识。与传统应用的账户体系相比,区块链身份有以下特点:
- 无需注册:用户无需填写表单注册,连接钱包即完成”注册”
- 自主托管:用户完全控制自己的私钥和应用访问权限
- 密码学验证:通过签名消息验证身份,无需服务器存储密码
typescript
// 连接钱包获取用户地址
import { ethers } from 'ethers';
async function connectWallet(): Promise<string> {
// 检查浏览器是否有钱包扩展
if (typeof window.ethereum !== 'undefined') {
try {
// 请求用户授权连接
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
if (accounts.length > 0) {
const address = accounts[0];
console.log('Connected wallet:', address);
return address;
}
} catch (error) {
console.error('Connection rejected:', error);
throw new Error('User rejected connection');
}
} else {
throw new Error('No wallet detected');
}
}
// 获取当前已连接的账户
async function getCurrentAccount(): Promise<string | null> {
if (typeof window.ethereum !== 'undefined') {
const accounts = await window.ethereum.request({
method: 'eth_accounts'
});
return accounts.length > 0 ? accounts[0] : null;
}
return null;
}
1.2 Web3认证流程
标准的Web3认证流程包括以下步骤:
- 连接请求:前端请求用户连接钱包
- 签名验证:前端生成随机挑战,用户签名
- 服务端验证:服务端验证签名,恢复用户地址
- 会话创建:验证通过后创建用户会话
plaintext
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ 前端 │ │ 钱包 │ │ 服务端 │
└──────┬──────┘ └──────┬───────┘ └──────┬──────┘
│ │ │
│ 1. 请求连接 │ │
│──────────────────>│ │
│ │ │
│ 2. 签名挑战 │ │
│<──────────────────│ │
│ │ │
│ 3. 签名结果 │ │
│──────────────────>│ │
│ │ │
│ │ 4. 转发签名 │
│ │──────────────────>│
│ │ │
│ │ 5. 验证并返回JWT │
│ │<──────────────────│
│ │ │
│ 6. 返回认证令牌 │ │
│<────────────────────────────────────────│
二、签名消息认证实现
2.1 服务端挑战生成
服务端需要生成唯一的挑战值,防止重放攻击:
typescript
// 服务端:生成和管理认证挑战
import { randomBytes, createHash } from 'crypto';
interface AuthChallenge {
challenge: string;
expiresAt: Date;
nonce: string;
}
// 内存存储,实际生产应使用Redis
const pendingChallenges = new Map<string, AuthChallenge>();
export class AuthChallengeService {
// 生成新的挑战
generateChallenge(address: string): AuthChallenge {
// 生成随机挑战
const challenge = this.createChallengeString();
const nonce = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5分钟有效期
const authChallenge: AuthChallenge = {
challenge,
expiresAt,
nonce
};
// 存储挑战,关联到用户地址
pendingChallenges.set(address.toLowerCase(), authChallenge);
return authChallenge;
}
// 创建标准格式的挑战消息
private createChallengeString(): string {
const domain = 'your-dapp-domain.com';
const uri = 'https://your-dapp-domain.com/auth';
const version = '1';
const chainId = 1; // Ethereum mainnet
return `Sign this message to authenticate with ${domain}.
This request will not trigger a blockchain transaction or cost any gas fees.
Wallet address:
[Your wallet address]
Nonce: ${randomBytes(16).toString('hex')}
Version: ${version}
Chain ID: ${chainId}
Issued At: ${new Date().toISOString()}
Expiry: ${new Date(Date.now() + 5 * 60 * 1000).toISOString()}`;
}
// 验证并消费挑战
async consumeChallenge(
address: string,
signature: string
): Promise<{ valid: boolean; error?: string }> {
const normalizedAddress = address.toLowerCase();
const challenge = pendingChallenges.get(normalizedAddress);
if (!challenge) {
return { valid: false, error: 'No pending challenge' };
}
// 检查过期
if (new Date() > challenge.expiresAt) {
pendingChallenges.delete(normalizedAddress);
return { valid: false, error: 'Challenge expired' };
}
// 验证签名
const isValid = await this.verifySignature(
address,
challenge.challenge,
signature
);
if (isValid) {
// 消费后删除挑战(防止重放)
pendingChallenges.delete(normalizedAddress);
return { valid: true };
}
return { valid: false, error: 'Invalid signature' };
}
// 验证ECDSA签名
private async verifySignature(
address: string,
message: string,
signature: string
): Promise<boolean> {
// 使用ethers.js验证签名
const recoveredAddress = ethers.utils.verifyMessage(message, signature);
return recoveredAddress.toLowerCase() === address.toLowerCase();
}
}
2.2 前端签名流程
typescript
// 前端:发起签名认证请求
import { ethers } from 'ethers';
interface AuthResponse {
success: boolean;
token?: string;
error?: string;
}
interface Challenge {
challenge: string;
expiresAt: string;
}
export class Web3AuthService {
private provider: ethers.providers.Web3Provider | null = null;
// 初始化Provider
async initializeProvider(): Promise<void> {
if (typeof window.ethereum !== 'undefined') {
this.provider = new ethers.providers.Web3Provider(window.ethereum);
} else {
throw new Error('No wallet detected');
}
}
// 获取挑战
async requestChallenge(address: string): Promise<Challenge> {
const response = await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address })
});
if (!response.ok) {
throw new Error('Failed to get challenge');
}
return response.json();
}
// 执行签名认证
async authenticate(): Promise<AuthResponse> {
if (!this.provider) {
await this.initializeProvider();
}
try {
// 1. 获取签名者地址
const signer = this.provider!.getSigner();
const address = await signer.getAddress();
// 2. 请求服务端获取挑战
const challenge = await this.requestChallenge(address);
// 3. 用户签名挑战
const signature = await signer.signMessage(challenge.challenge);
// 4. 发送签名到服务端验证
const verifyResponse = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address,
signature,
challenge: challenge.challenge
})
});
const result = await verifyResponse.json();
if (result.success) {
// 存储认证令牌
this.storeAuthToken(result.token);
return { success: true, token: result.token };
}
return { success: false, error: result.error };
} catch (error) {
console.error('Authentication failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
// 存储认证令牌
private storeAuthToken(token: string): void {
// 使用HttpOnly cookie更安全,这里简化处理
localStorage.setItem('auth_token', token);
}
}
三、会话管理方案
3.1 JWT会话令牌
认证通过后,使用JWT管理用户会话:
typescript
// JWT生成与验证
import jwt from 'jsonwebtoken';
import { createHmac } from 'crypto';
interface SessionPayload {
address: string;
issuedAt: number;
expiresAt: number;
sessionId: string;
}
class SessionManager {
private readonly secret: string;
private readonly issuer = 'your-dapp';
constructor() {
// 生产环境从环境变量获取
this.secret = process.env.JWT_SECRET || 'your-secret-key';
}
// 生成JWT
generateToken(address: string): string {
const now = Math.floor(Date.now() / 1000);
const payload: SessionPayload = {
address: address.toLowerCase(),
issuedAt: now,
expiresAt: now + 24 * 60 * 60, // 24小时
sessionId: this.generateSessionId()
};
return jwt.sign(payload, this.secret, {
algorithm: 'HS256',
issuer: this.issuer
});
}
// 验证JWT
verifyToken(token: string): { valid: boolean; payload?: SessionPayload; error?: string } {
try {
const payload = jwt.verify(token, this.secret, {
algorithms: ['HS256'],
issuer: this.issuer
}) as SessionPayload;
// 检查是否过期
const now = Math.floor(Date.now() / 1000);
if (payload.expiresAt < now) {
return { valid: false, error: 'Token expired' };
}
return { valid: true, payload };
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : 'Invalid token'
};
}
}
// 刷新令牌
refreshToken(oldToken: string): { success: boolean; token?: string; error?: string } {
const verifyResult = this.verifyToken(oldToken);
if (!verifyResult.valid || !verifyResult.payload) {
return { success: false, error: verifyResult.error };
}
// 检查是否在刷新窗口内(过期前2小时内)
const now = Math.floor(Date.now() / 1000);
const refreshWindow = verifyResult.payload.expiresAt - 2 * 60 * 60;
if (now < refreshWindow) {
return { success: false, error: 'Too early to refresh' };
}
// 生成新令牌
const newToken = this.generateToken(verifyResult.payload.address);
return { success: true, token: newToken };
}
// 撤销令牌
async revokeToken(token: string): Promise<void> {
// 将token加入黑名单
const { payload } = this.verifyToken(token);
if (payload) {
await this.addToBlacklist(payload.sessionId, payload.expiresAt);
}
}
// 生成会话ID
private generateSessionId(): string {
return createHmac('sha256', this.secret)
.update(Math.random().toString())
.digest('hex');
}
// 黑名单管理(使用Redis)
private async addToBlacklist(sessionId: string, expiresAt: number): Promise<void> {
// TTL设为token的剩余有效期
const ttl = expiresAt - Math.floor(Date.now() / 1000);
// redis.setex(`blacklist:${sessionId}`, ttl, 'revoked');
console.log(`Blacklisting session ${sessionId} for ${ttl}s`);
}
}
3.2 安全的会话存储
typescript
// 前端会话管理
class SessionStorage {
private readonly TOKEN_KEY = 'web3_session';
private readonly ADDRESS_KEY = 'web3_address';
private refreshTimer: NodeJS.Timeout | null = null;
// 保存会话
saveSession(token: string, address: string): void {
const session = {
token,
address,
createdAt: Date.now()
};
localStorage.setItem(this.TOKEN_KEY, JSON.stringify(session));
localStorage.setItem(this.ADDRESS_KEY, address);
// 设置自动刷新
this.scheduleRefresh(token);
}
// 获取当前会话
getSession(): { token: string; address: string } | null {
const stored = localStorage.getItem(this.TOKEN_KEY);
if (!stored) return null;
try {
const session = JSON.parse(stored);
return {
token: session.token,
address: session.address
};
} catch {
this.clearSession();
return null;
}
}
// 检查会话是否有效
async isSessionValid(): Promise<boolean> {
const session = this.getSession();
if (!session) return false;
try {
const response = await fetch('/api/auth/validate', {
headers: {
'Authorization': `Bearer ${session.token}`
}
});
return response.ok;
} catch {
return false;
}
}
// 清除会话
clearSession(): void {
localStorage.removeItem(this.TOKEN_KEY);
localStorage.removeItem(this.ADDRESS_KEY);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
// 定期刷新令牌
private scheduleRefresh(token: string): void {
// 在过期前1小时刷新
const refreshTime = 60 * 60 * 1000; // 1小时
const tokenData = this.parseJwt(token);
if (tokenData && tokenData.exp) {
const expiresIn = tokenData.exp * 1000 - Date.now();
const timeUntilRefresh = expiresIn - refreshTime;
if (timeUntilRefresh > 0) {
this.refreshTimer = setTimeout(
() => this.refreshSession(),
timeUntilRefresh
);
}
}
}
// 刷新会话
private async refreshSession(): Promise<void> {
const session = this.getSession();
if (!session) return;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.token}`
}
});
if (response.ok) {
const { token } = await response.json();
this.saveSession(token, session.address);
} else {
this.clearSession();
// 触发重新认证
window.dispatchEvent(new CustomEvent('auth:required'));
}
} catch {
this.clearSession();
}
}
// 解析JWT(不验证)
private parseJwt(token: string): any {
try {
const base64 = token.split('.')[1];
return JSON.parse(atob(base64));
} catch {
return null;
}
}
}
四、React中的认证实现
4.1 认证上下文
tsx
// React认证上下文
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
interface User {
address: string;
isConnected: boolean;
isAuthenticated: boolean;
}
interface AuthContextValue {
user: User | null;
isLoading: boolean;
error: string | null;
connect: () => Promise<void>;
disconnect: () => void;
authenticate: () => Promise<boolean>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
// 初始化检测
useEffect(() => {
const initAuth = async () => {
if (typeof window.ethereum !== 'undefined') {
const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
setProvider(web3Provider);
// 检查已连接的账户
try {
const accounts = await window.ethereum.request({
method: 'eth_accounts'
});
if (accounts.length > 0) {
const address = accounts[0];
const token = localStorage.getItem('auth_token');
// 验证现有令牌
if (token) {
const isValid = await validateToken(token);
if (isValid) {
setUser({
address,
isConnected: true,
isAuthenticated: true
});
} else {
localStorage.removeItem('auth_token');
setUser({
address,
isConnected: true,
isAuthenticated: false
});
}
} else {
setUser({
address,
isConnected: true,
isAuthenticated: false
});
}
}
} catch (err) {
console.error('Init auth error:', err);
}
}
setIsLoading(false);
};
initAuth();
// 监听账户变化
if (typeof window.ethereum !== 'undefined') {
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
}
return () => {
if (typeof window.ethereum !== 'undefined') {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
}
};
}, []);
const handleAccountsChanged = useCallback((accounts: string[]) => {
if (accounts.length === 0) {
setUser(null);
} else if (user) {
setUser(prev => prev ? {
...prev,
address: accounts[0],
isAuthenticated: false
} : null);
}
}, [user]);
const handleChainChanged = useCallback(() => {
// 链变化后刷新页面
window.location.reload();
}, []);
const connect = async () => {
if (!provider) {
setError('No wallet detected');
return;
}
setIsLoading(true);
setError(null);
try {
await provider.send('eth_requestAccounts', []);
const accounts = await provider.listAccounts();
if (accounts.length > 0) {
setUser({
address: accounts[0].address,
isConnected: true,
isAuthenticated: false
});
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed');
} finally {
setIsLoading(false);
}
};
const disconnect = () => {
localStorage.removeItem('auth_token');
setUser(null);
};
const authenticate = async (): Promise<boolean> => {
if (!provider || !user) return false;
setIsLoading(true);
setError(null);
try {
const signer = provider.getSigner();
const address = await signer.getAddress();
// 1. 获取挑战
const challengeRes = await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address })
});
if (!challengeRes.ok) throw new Error('Failed to get challenge');
const { challenge } = await challengeRes.json();
// 2. 签名
const signature = await signer.signMessage(challenge);
// 3. 验证
const verifyRes = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, signature, challenge })
});
if (!verifyRes.ok) throw new Error('Verification failed');
const { token } = await verifyRes.json();
localStorage.setItem('auth_token', token);
setUser(prev => prev ? {
...prev,
isAuthenticated: true
} : null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
return false;
} finally {
setIsLoading(false);
}
};
const validateToken = async (token: string): Promise<boolean> => {
try {
const res = await fetch('/api/auth/validate', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.ok;
} catch {
return false;
}
};
return (
<AuthContext.Provider value={{
user,
isLoading,
error,
connect,
disconnect,
authenticate
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
4.2 认证组件
tsx
// 连接钱包按钮组件
import { useAuth } from './AuthContext';
import { Button } from './ui/Button';
export function WalletConnectButton() {
const { user, isLoading, error, connect, authenticate } = useAuth();
if (isLoading) {
return (
<Button disabled>
<span className="animate-spin mr-2">⏳</span>
Loading...
</Button>
);
}
if (!user?.isConnected) {
return (
<Button onClick={connect}>
Connect Wallet
</Button>
);
}
if (!user.isAuthenticated) {
return (
<Button onClick={authenticate}>
Sign In
</Button>
);
}
return (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">
{user.address.slice(0, 6)}...{user.address.slice(-4)}
</span>
<Button variant="ghost">
Disconnect
</Button>
</div>
);
}
五、安全最佳实践
5.1 防止常见攻击
typescript
// 安全检查工具
export class SecurityUtils {
// 验证地址格式
static isValidAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
// 验证签名格式
static isValidSignature(signature: string): boolean {
return /^0x[a-fA-F0-9]{130}$/.test(signature);
}
// 防止重放攻击:检查nonce
static async checkNonce(address: string): Promise<boolean> {
const response = await fetch(`/api/auth/nonce/${address}`);
const { used } = await response.json();
return !used;
}
// 检测钓鱼风险
static checkDomainMismatch(domain: string): boolean {
const expectedDomain = process.env.REACT_APP_DOMAIN;
return domain !== expectedDomain;
}
// 签名消息时显示清晰警告
static getSignMessageWarning(domain: string): string {
return `
⚠️ Security Notice:
Before signing, please verify:
1. URL is correct: https://${domain}
2. You are not on a phishing site
3. The message doesn't request token transfers
This signature will NOT trigger any blockchain transaction.
`.trim();
}
}
5.2 认证中间件
typescript
// Express认证中间件
import { Request, Response, NextFunction } from 'express';
import { SessionManager } from './SessionManager';
const sessionManager = new SessionManager();
export interface AuthenticatedRequest extends Request {
user?: {
address: string;
sessionId: string;
};
}
export function authMiddleware(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'No token provided' });
return;
}
const token = authHeader.substring(7);
const result = sessionManager.verifyToken(token);
if (!result.valid || !result.payload) {
res.status(401).json({ error: result.error });
return;
}
req.user = {
address: result.payload.address,
sessionId: result.payload.sessionId
};
next();
}
// 可选认证中间件(不强制要求登录)
export function optionalAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
next();
return;
}
const token = authHeader.substring(7);
const result = sessionManager.verifyToken(token);
if (result.valid && result.payload) {
req.user = {
address: result.payload.address,
sessionId: result.payload.sessionId
};
}
next();
}
六、总结
本文系统讲解了DApp用户认证与会话管理的完整解决方案:
- 认证基础:理解了区块链身份与钱包的关系
- 签名认证:掌握了基于签名消息的认证流程
- 会话管理:学会了JWT令牌的生成、验证和刷新
- React集成:完成了认证上下文和组件的实现
- 安全实践:了解了常见攻击防护和最佳实践
一个好的DApp认证系统应该在安全性和用户体验之间找到平衡。记住以下原则:
- 始终使用挑战-响应的认证模式
- JWT令牌要有合理的过期时间
- 前端要处理好链变化和账户切换
- 对敏感操作考虑二次验证

发表回复