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_address | string | 是 | 以太坊兼容的錢包地址(校驗和格式或小寫) |
| chain_id | string | 是 | 區塊鏈網絡鏈 ID(例如:"1" 為以太坊主網,"97" 為 BSC 測試網) |
| message | string | 是 | 由錢包簽署的純文本消息 |
| signature | string | 是 | 由 personal_sign 方法生成的十六進制編碼簽名 |
消息格式規範
模板
text
Wallet address: {wallet_address}
ChainId: {chain_id}
Timestamp: {utc_timestamp}
Nonce: {unique_nonce}必填字段
| 字段 | 格式 | 說明 | 示例 |
|---|---|---|---|
| wallet_address | 以太坊地址 | 必須與簽名錢包地址匹配 | 0x1234567890123456789012345678901234567890 |
| chain_id | 數字字符串 | 必須與當前區塊鏈網絡匹配 | 97 |
| utc_timestamp | ISO 8601 UTC | 當前 UTC 時間,防止重放攻擊 | 2025-11-12T01:59:44Z |
| nonce | UUID 或隨機字符串 | 唯一標識符,防止重放攻擊 | 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 };
}響應參數
成功響應
| 字段 | 類型 | 說明 |
|---|---|---|
| code | integer | 狀態碼(0 = 成功) |
| data | object | 響應數據對象 |
| data.jwt | string | 用於 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();
}錯誤碼
| 代碼 | 消息 | 說明 | 解決方案 |
|---|---|---|---|
| 400 | Invalid signature | 簽名驗證失敗 | 確保消息和簽名匹配,檢查錢包連接 |
| 400 | Invalid message format | 消息不包含必填字段 | 遵循消息格式規範 |
| 400 | Timestamp expired | 時間戳過舊或過於未來 | 使用當前時間戳生成新消息 |
| 400 | Nonce already used | 隨機數已被使用 | 生成新的唯一隨機數 |
| 400 | Invalid wallet address | 錢包地址格式無效 | 提供有效的以太坊地址 |
| 400 | Chain ID mismatch | 鏈 ID 與預期網絡不匹配 | 驗證目標網絡的正確鏈 ID |
| 401 | Token expired | JWT 令牌已過期 | 重新認證以獲取新令牌 |
| 401 | Invalid token | JWT 令牌無效或格式錯誤 | 重新認證以獲取新令牌 |
| 429 | Too many requests | 超過速率限制 | 等待後重試 |
| 500 | Internal server error | 服務器端錯誤 | 如持續出現請聯繫支持 |
安全最佳實踐
前端開發者
安全的令牌存儲
- 將 JWT 令牌存儲在內存或 httpOnly cookies 中
- 對於敏感應用避免使用 localStorage
- 登出時清除令牌
隨機數生成
- 使用加密安全的隨機值
- 永不重用隨機數
- 使用 UUID v4 或類似方法
時間戳驗證
- 如可用,使用服務器時間
- 考慮時鐘偏差
- 實施合理的時間窗口
簽名驗證
- 始終使用 personal_sign(而非 eth_sign)
- 簽名前驗證消息內容
- 向用戶清楚顯示消息
後端開發者
簽名驗證
- 驗證簽名與錢包地址匹配
- 驗證所有消息字段
- 檢查時間戳在可接受窗口內
防重放攻擊
- 存儲已使用的隨機數(帶過期時間)
- 驗證時間戳新鮮度
- 實施速率限制
令牌管理
- 設置適當的過期時間
- 使用安全的簽名算法(HS256 或 RS256)
- 實施令牌刷新機制
速率限制
- 限制每個 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');