跳轉到內容

API 認證授權

概述

此接口允許 Web3 用戶通過連接錢包並簽署消息進行身份驗證。驗證成功後,系統將頒發 JWT 令牌,用於授權後續的 API 請求。

認證流程

1. 前端生成包含錢包地址、鏈 ID、時間戳和隨機數的消息
2. 用戶使用錢包簽署消息(personal_sign)
3. 前端將簽名消息發送到此接口
4. 後端驗證簽名和錢包所有權
5. 後端頒發 JWT 令牌
6. 前端在所有後續 API 調用的 Authorization 標頭中包含 JWT 令牌

接口信息

接口: POST /api/v1/makers/connect

描述: 驗證 Web3 錢包連接並獲取用於 API 授權的 JWT 令牌

需要認證: 否(這是認證接口本身)

請求參數

字段類型必填說明
wallet_addressstring以太坊兼容的錢包地址(校驗和格式或小寫)
chain_idstring區塊鏈網絡鏈 ID(例如:"1" 為以太坊主網,"97" 為 BSC 測試網)
messagestring由錢包簽署的純文本消息
signaturestring由 personal_sign 方法生成的十六進制編碼簽名

消息格式規範

模板

text
Wallet address: {wallet_address}
ChainId: {chain_id}
Timestamp: {utc_timestamp}
Nonce: {unique_nonce}

必填字段

字段格式說明示例
wallet_address以太坊地址必須與簽名錢包地址匹配0x1234567890123456789012345678901234567890
chain_id數字字符串必須與當前區塊鏈網絡匹配97
utc_timestampISO 8601 UTC當前 UTC 時間,防止重放攻擊2025-11-12T01:59:44Z
nonceUUID 或隨機字符串唯一標識符,防止重放攻擊ce7e949b-2018-4cb7-bac0-246e1c146c60

格式規則

  • 每個字段必須單獨一行
  • 字段名和值用 : (冒號+空格)分隔
  • 字段順序可自定義
  • 允許額外的自定義字段,但會被忽略
  • 時間戳應在合理的時間窗口內(例如 ±5 分鐘)
  • 每次請求的隨機數必須唯一

消息示例

text
Wallet address: 0x1234567890123456789012345678901234567890
ChainId: 97
Timestamp: 2025-11-12T01:59:44Z
Nonce: ce7e949b-2018-4cb7-bac0-246e1c146c60

簽名生成

javascript
import { ethers } from 'ethers';

async function signMessage(walletAddress, chainId) {
	const provider = new ethers.BrowserProvider(window.ethereum);
	const signer = await provider.getSigner();

	const timestamp = new Date().toISOString().replace(/\.\d{3}/, '');
	const nonce = crypto.randomUUID();

	const message = `Wallet address: ${walletAddress}
ChainId: ${chainId}
Timestamp: ${timestamp}
Nonce: ${nonce}`;

	const signature = await signer.signMessage(message);

	return { message, signature };
}

響應參數

成功響應

字段類型說明
codeinteger狀態碼(0 = 成功)
dataobject響應數據對象
data.jwtstring用於 API 授權的 JWT 令牌

JWT 令牌結構

JWT 令牌包含:

  • Header(標頭): 算法和令牌類型
  • Payload(負載): 錢包地址、鏈 ID、過期時間
  • Signature(簽名): 服務器端簽名用於驗證

令牌過期時間:通常為 24 小時(由服務器配置)

請求示例

cURL

bash
curl -X POST "{API_BASE_URL}/api/v1/makers/connect" \
     -H "Content-Type: application/json" \
     -d '{
       "wallet_address": "0x1234567890123456789012345678901234567890",
       "chain_id": "97",
       "message": "Wallet address: 0x1234567890123456789012345678901234567890\nChainId: 97\nTimestamp: 2025-11-12T01:59:44Z\nNonce: ce7e949b-2018-4cb7-bac0-246e1c146c60",
       "signature": "0x8f3d2e1c4b5a6f7e8d9c0b1a2f3e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e01"
     }'

JavaScript (Fetch API)

javascript
const API_BASE_URL = process.env.API_BASE_URL; // 根據環境設置

async function authenticate(walletAddress, chainId, message, signature) {
	const response = await fetch(`${API_BASE_URL}/api/v1/makers/connect`, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({
			wallet_address: walletAddress,
			chain_id: chainId,
			message: message,
			signature: signature,
		}),
	});

	const data = await response.json();

	if (data.code === 0) {
		// 存儲 JWT 令牌
		localStorage.setItem('jwt_token', data.data.jwt);
		return data.data.jwt;
	} else {
		throw new Error(data.message || '認證失敗');
	}
}

響應示例

成功響應

json
{
	"code": 0,
	"data": {
		"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3YWxsZXRfYWRkcmVzcyI6IjB4MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MCIsImNoYWluX2lkIjoiOTciLCJleHAiOjE3MzE0NTU5ODR9.K7Xz9Ym3Qw5Rt8Pn2Lv6Jh4Fg1Ds0Cx9Bw7Au5Nt3Km"
	}
}

錯誤響應

json
{
	"code": 400,
	"message": "Invalid signature"
}

使用 JWT 令牌進行 API 授權

獲取 JWT 令牌後,在所有後續 API 請求的 Authorization 標頭中包含它:

標頭格式

Authorization: Bearer <jwt_token>

請求示例

bash
curl -X GET "{API_BASE_URL}/api/v1/orders" \
     -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

JavaScript 示例

javascript
async function fetchOrders(jwtToken) {
	const response = await fetch(`${API_BASE_URL}/api/v1/orders`, {
		method: 'GET',
		headers: {
			Authorization: `Bearer ${jwtToken}`,
			'Content-Type': 'application/json',
		},
	});

	return await response.json();
}

錯誤碼

代碼消息說明解決方案
400Invalid signature簽名驗證失敗確保消息和簽名匹配,檢查錢包連接
400Invalid message format消息不包含必填字段遵循消息格式規範
400Timestamp expired時間戳過舊或過於未來使用當前時間戳生成新消息
400Nonce already used隨機數已被使用生成新的唯一隨機數
400Invalid wallet address錢包地址格式無效提供有效的以太坊地址
400Chain ID mismatch鏈 ID 與預期網絡不匹配驗證目標網絡的正確鏈 ID
401Token expiredJWT 令牌已過期重新認證以獲取新令牌
401Invalid tokenJWT 令牌無效或格式錯誤重新認證以獲取新令牌
429Too many requests超過速率限制等待後重試
500Internal server error服務器端錯誤如持續出現請聯繫支持

安全最佳實踐

前端開發者

  1. 安全的令牌存儲

    • 將 JWT 令牌存儲在內存或 httpOnly cookies 中
    • 對於敏感應用避免使用 localStorage
    • 登出時清除令牌
  2. 隨機數生成

    • 使用加密安全的隨機值
    • 永不重用隨機數
    • 使用 UUID v4 或類似方法
  3. 時間戳驗證

    • 如可用,使用服務器時間
    • 考慮時鐘偏差
    • 實施合理的時間窗口
  4. 簽名驗證

    • 始終使用 personal_sign(而非 eth_sign)
    • 簽名前驗證消息內容
    • 向用戶清楚顯示消息

後端開發者

  1. 簽名驗證

    • 驗證簽名與錢包地址匹配
    • 驗證所有消息字段
    • 檢查時間戳在可接受窗口內
  2. 防重放攻擊

    • 存儲已使用的隨機數(帶過期時間)
    • 驗證時間戳新鮮度
    • 實施速率限制
  3. 令牌管理

    • 設置適當的過期時間
    • 使用安全的簽名算法(HS256 或 RS256)
    • 實施令牌刷新機制
  4. 速率限制

    • 限制每個 IP 的認證嘗試次數
    • 限制每個錢包地址的嘗試次數
    • 實施指數退避

令牌刷新

當 JWT 令牌過期時,使用此接口重新認證以獲取新令牌。考慮在過期前實施自動令牌刷新:

javascript
async function refreshTokenIfNeeded(jwtToken) {
	// 解碼 JWT 以檢查過期時間
	const payload = JSON.parse(atob(jwtToken.split('.')[1]));
	const expiresAt = payload.exp * 1000; // 轉換為毫秒
	const now = Date.now();

	// 如果令牌在 1 小時內過期則刷新
	if (expiresAt - now < 3600000) {
		// 重新認證以獲取新令牌
		const { message, signature } = await signMessage(walletAddress, chainId);
		return await authenticate(walletAddress, chainId, message, signature);
	}

	return jwtToken;
}

完整集成示例

javascript
import { ethers } from 'ethers';

class StockProtocolAuth {
	constructor(apiBaseUrl) {
		this.apiBaseUrl = apiBaseUrl;
		this.jwtToken = null;
	}

	async connect() {
		// 請求錢包連接
		await window.ethereum.request({ method: 'eth_requestAccounts' });

		const provider = new ethers.BrowserProvider(window.ethereum);
		const signer = await provider.getSigner();
		const walletAddress = await signer.getAddress();
		const network = await provider.getNetwork();
		const chainId = network.chainId.toString();

		// 生成消息
		const timestamp = new Date().toISOString().replace(/\.\d{3}/, '');
		const nonce = crypto.randomUUID();
		const message = `Wallet address: ${walletAddress}
ChainId: ${chainId}
Timestamp: ${timestamp}
Nonce: ${nonce}`;

		// 簽署消息
		const signature = await signer.signMessage(message);

		// 認證
		const response = await fetch(`${this.apiBaseUrl}/api/v1/makers/connect`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({
				wallet_address: walletAddress,
				chain_id: chainId,
				message: message,
				signature: signature,
			}),
		});

		const data = await response.json();

		if (data.code === 0) {
			this.jwtToken = data.data.jwt;
			return this.jwtToken;
		} else {
			throw new Error(data.message || '認證失敗');
		}
	}

	async apiCall(endpoint, options = {}) {
		if (!this.jwtToken) {
			throw new Error('未認證。請先調用 connect()。');
		}

		const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {
			...options,
			headers: {
				...options.headers,
				Authorization: `Bearer ${this.jwtToken}`,
				'Content-Type': 'application/json',
			},
		});

		return await response.json();
	}
}

// 使用方法
const API_BASE_URL = process.env.API_BASE_URL; // 例如:'https://uat-api.example.com' 或 'https://api.example.com'
const auth = new StockProtocolAuth(API_BASE_URL);
await auth.connect();
const orders = await auth.apiCall('/api/v1/orders');

相關文檔