Browse Source

提供pinnacle接口

flyzto 1 week ago
parent
commit
ae3136f67b
3 changed files with 346 additions and 2 deletions
  1. 216 0
      pinnacle/PINNACLE_PROXY_AUTH.md
  2. 3 2
      pinnacle/main.js
  3. 127 0
      pinnacle/middleware/requirePinnacleProxyAuth.js

+ 216 - 0
pinnacle/PINNACLE_PROXY_AUTH.md

@@ -0,0 +1,216 @@
+# Pinnacle Proxy API Auth
+
+`/api/pinnacle` 不需要登录态,但每个调用方需要配置独立用户和密钥,并通过动态签名访问接口。
+
+## 服务端配置
+
+在 `pinnacle/.env` 中配置:
+
+```env
+PINNACLE_PROXY_USERS=alice:aliceSecret:/events,/v3/sports;bob:bobSecret:*
+```
+
+格式:
+
+```txt
+用户名:密钥:权限列表;用户名:密钥:权限列表
+```
+
+说明:
+
+- `用户名`:前端请求时放在 `X-Api-User`。
+- `密钥`:只保存在服务端和可信调用方,不在请求中明文传递。
+- `权限列表`:逗号分隔,写 `/api/pinnacle` 后面的实际路径。
+- `*` 表示允许访问所有 `/api/pinnacle` 接口。
+
+示例:
+
+```env
+PINNACLE_PROXY_USERS=viewer:viewerSecret:/events,/v2/currencies;trader:traderSecret:/events,/v4/bets/place,/v4/bets/parlay
+```
+
+对应权限:
+
+- `viewer` 可以访问 `/api/pinnacle/events` 和 `/api/pinnacle/v2/currencies`
+- `trader` 可以访问 `/api/pinnacle/events`、`/api/pinnacle/v4/bets/place`、`/api/pinnacle/v4/bets/parlay`
+
+## 请求头
+
+前端每次请求 `/api/pinnacle` 都需要带:
+
+```txt
+X-Api-User: alice
+X-Api-Timestamp: 1710000000000
+X-Api-Nonce: random-string
+X-Api-Signature: hmac-sha256-hex
+```
+
+说明:
+
+- `X-Api-Timestamp`:当前毫秒时间戳,允许和服务端相差 5 分钟。
+- `X-Api-Nonce`:每次请求都要不同,防止重放。
+- `X-Api-Signature`:用用户密钥对签名内容做 HMAC-SHA256。
+
+## 签名内容
+
+签名字符串由 6 行组成:
+
+```txt
+METHOD
+PATH
+QUERY_JSON
+BODY_SHA256
+TIMESTAMP
+NONCE
+```
+
+示例:
+
+```txt
+GET
+/events
+{"id":"123"}
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+1710000000000
+abc123
+```
+
+其中:
+
+- `METHOD`:大写 HTTP 方法,例如 `GET`、`POST`
+- `PATH`:`/api/pinnacle` 后面的路径,例如 `/events`、`/v4/bets/place`
+- `QUERY_JSON`:query 参数按 key 排序后的 JSON 字符串
+- `BODY_SHA256`:body JSON 按 key 排序后做 SHA256;空 body 使用空字符串的 SHA256
+- `TIMESTAMP`:同 `X-Api-Timestamp`
+- `NONCE`:同 `X-Api-Nonce`
+
+## 前端示例
+
+浏览器端可使用 Web Crypto API:
+
+```js
+const stableStringify = (value) => {
+  if (Array.isArray(value)) {
+    return `[${value.map(stableStringify).join(',')}]`;
+  }
+
+  if (value && typeof value === 'object') {
+    return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
+  }
+
+  return JSON.stringify(value);
+};
+
+const toHex = (buffer) => {
+  return [...new Uint8Array(buffer)]
+    .map((byte) => byte.toString(16).padStart(2, '0'))
+    .join('');
+};
+
+const sha256 = async (text) => {
+  const data = new TextEncoder().encode(text);
+  const hash = await crypto.subtle.digest('SHA-256', data);
+  return toHex(hash);
+};
+
+const hmacSha256 = async (secret, text) => {
+  const key = await crypto.subtle.importKey(
+    'raw',
+    new TextEncoder().encode(secret),
+    { name: 'HMAC', hash: 'SHA-256' },
+    false,
+    ['sign'],
+  );
+  const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text));
+  return toHex(signature);
+};
+
+const createPinnacleHeaders = async ({ method, path, query = {}, body, user, secret }) => {
+  const timestamp = String(Date.now());
+  const nonce = crypto.randomUUID();
+  const bodyText = body === undefined || body === null ? '' : stableStringify(body);
+  const bodyHash = await sha256(bodyText);
+
+  const payload = [
+    method.toUpperCase(),
+    path,
+    stableStringify(query),
+    bodyHash,
+    timestamp,
+    nonce,
+  ].join('\n');
+
+  const signature = await hmacSha256(secret, payload);
+
+  return {
+    'X-Api-User': user,
+    'X-Api-Timestamp': timestamp,
+    'X-Api-Nonce': nonce,
+    'X-Api-Signature': signature,
+  };
+};
+```
+
+GET 请求:
+
+```js
+const user = 'alice';
+const secret = 'aliceSecret';
+const path = '/events';
+const query = { ids: '123,456' };
+const search = new URLSearchParams(query).toString();
+
+const headers = await createPinnacleHeaders({
+  method: 'GET',
+  path,
+  query,
+  user,
+  secret,
+});
+
+const res = await fetch(`/api/pinnacle${path}?${search}`, { headers });
+const data = await res.json();
+```
+
+POST 请求:
+
+```js
+const user = 'trader';
+const secret = 'traderSecret';
+const path = '/v4/bets/place';
+const body = {
+  uniqueRequestId: crypto.randomUUID(),
+  stake: 100,
+  winRiskStake: 'RISK',
+  oddsFormat: 'DECIMAL',
+  lineId: 123456,
+};
+
+const headers = await createPinnacleHeaders({
+  method: 'POST',
+  path,
+  body,
+  user,
+  secret,
+});
+
+const res = await fetch(`/api/pinnacle${path}`, {
+  method: 'POST',
+  headers: {
+    ...headers,
+    'Content-Type': 'application/json',
+  },
+  body: JSON.stringify(body),
+});
+
+const data = await res.json();
+```
+
+## 常见错误
+
+- `401 Missing API signature headers`:缺少签名请求头。
+- `401 Invalid API timestamp`:时间戳过期或不是毫秒时间戳。
+- `401 API nonce has already been used`:nonce 重复。
+- `401 Invalid API signature`:签名不匹配。
+- `403 Forbidden`:用户没有该接口权限。
+

+ 3 - 2
pinnacle/main.js

@@ -4,6 +4,7 @@ import express from 'express';
 
 import Logs from "./libs/logs.js";
 import requireInternalToken from './middleware/requireInternalToken.js';
+import requirePinnacleProxyAuth from './middleware/requirePinnacleProxyAuth.js';
 import pinnacleProxyRoutes from './routes/pinnacleProxy.js';
 import tradingRoutes from './routes/trading.js';
 
@@ -15,7 +16,7 @@ app.use((req, res, next) => {
   const origin = req.headers.origin;
   res.header('Access-Control-Allow-Origin', origin || '*');
   res.header('Access-Control-Allow-Credentials', 'true');
-  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Internal-Token');
+  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Internal-Token, X-Api-User, X-Api-Timestamp, X-Api-Nonce, X-Api-Signature');
   res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
   res.header('Vary', 'Origin');
 
@@ -69,7 +70,7 @@ app.get('/health', (req, res) => {
   res.sendSuccess({ service: 'polymarket', status: 'ok' });
 });
 
-app.use('/api/pinnacle', pinnacleProxyRoutes);
+app.use('/api/pinnacle', requirePinnacleProxyAuth, pinnacleProxyRoutes);
 app.use('/api/trading', requireInternalToken, tradingRoutes);
 
 // 启动服务

+ 127 - 0
pinnacle/middleware/requirePinnacleProxyAuth.js

@@ -0,0 +1,127 @@
+import crypto from 'node:crypto';
+
+import { safeEqual } from '../libs/auth.js';
+
+const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
+const usedNonces = new Map();
+
+const normalizePath = (path) => {
+  if (!path || path === '/') {
+    return '/';
+  }
+  return path.endsWith('/') ? path.slice(0, -1) : path;
+};
+
+const stableStringify = (value) => {
+  if (Array.isArray(value)) {
+    return `[${value.map(stableStringify).join(',')}]`;
+  }
+
+  if (value && typeof value === 'object') {
+    return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
+  }
+
+  return JSON.stringify(value);
+};
+
+const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
+
+const getUsers = () => {
+  return process.env.PINNACLE_PROXY_USERS?.split(';')
+  .map(item => item.trim())
+  .filter(Boolean)
+  .map(item => {
+    const [username, secret, permissions=''] = item.split(':');
+    return {
+      permissions: permissions.split(',').map(permission => normalizePath(permission.trim())).filter(Boolean),
+      secret,
+      username,
+    };
+  })
+  .filter(user => user.username && user.secret) ?? [];
+};
+
+const getUser = (username) => getUsers().find(user => safeEqual(user.username, username));
+
+const cleanupUsedNonces = () => {
+  const now = Date.now();
+  usedNonces.forEach((expiresAt, key) => {
+    if (expiresAt <= now) {
+      usedNonces.delete(key);
+    }
+  });
+};
+
+const checkNonce = (username, nonce) => {
+  cleanupUsedNonces();
+
+  const key = `${username}:${nonce}`;
+  if (usedNonces.has(key)) {
+    return false;
+  }
+
+  usedNonces.set(key, Date.now() + MAX_CLOCK_SKEW_MS);
+  return true;
+};
+
+const hasPermission = (user, path) => {
+  const targetPath = normalizePath(path);
+  return user.permissions.includes('*') || user.permissions.includes(targetPath);
+};
+
+const getBodyHash = (body) => {
+  if (body === undefined || body === null || Object.keys(body).length === 0) {
+    return sha256('');
+  }
+  return sha256(stableStringify(body));
+};
+
+const buildSigningPayload = (req, timestamp, nonce) => [
+  req.method.toUpperCase(),
+  normalizePath(req.path),
+  stableStringify(req.query ?? {}),
+  getBodyHash(req.body),
+  timestamp,
+  nonce,
+].join('\n');
+
+const requirePinnacleProxyAuth = (req, res, next) => {
+  const username = req.get('x-api-user');
+  const timestamp = req.get('x-api-timestamp');
+  const nonce = req.get('x-api-nonce');
+  const signature = req.get('x-api-signature');
+
+  if (!username || !timestamp || !nonce || !signature) {
+    return res.unauthorized('Missing API signature headers');
+  }
+
+  const requestTime = Number(timestamp);
+  if (!Number.isFinite(requestTime) || Math.abs(Date.now() - requestTime) > MAX_CLOCK_SKEW_MS) {
+    return res.unauthorized('Invalid API timestamp');
+  }
+
+  const user = getUser(username);
+  if (!user) {
+    return res.unauthorized('Invalid API user');
+  }
+
+  if (!hasPermission(user, req.path)) {
+    return res.status(403).json({ statusCode: 403, code: -1, message: 'Forbidden' });
+  }
+
+  const payload = buildSigningPayload(req, timestamp, nonce);
+  const expectedSignature = crypto.createHmac('sha256', user.secret).update(payload).digest('hex');
+
+  if (!safeEqual(signature, expectedSignature)) {
+    return res.unauthorized('Invalid API signature');
+  }
+
+  if (!checkNonce(username, nonce)) {
+    return res.unauthorized('API nonce has already been used');
+  }
+
+  req.pinnacleProxyUser = { username };
+  return next();
+};
+
+export default requirePinnacleProxyAuth;