123 lines
3.9 KiB
JavaScript
123 lines
3.9 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
|
|
const usedNonces = new Map();
|
|
|
|
function normalizeIp(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^::ffff:/i, '')
|
|
.replace(/^\[|\]$/g, '');
|
|
}
|
|
|
|
function resolveRequestIp(req = {}) {
|
|
const socketIp = normalizeIp(req.socket?.remoteAddress || '');
|
|
const forwarded = String(req.headers?.['x-forwarded-for'] || '')
|
|
.split(',')[0]
|
|
.trim();
|
|
const requestIp = normalizeIp(req.ip || forwarded);
|
|
return ['127.0.0.1', '::1'].includes(socketIp)
|
|
? (requestIp || normalizeIp(forwarded) || socketIp)
|
|
: socketIp;
|
|
}
|
|
|
|
function isRequestIpAllowed(requestIp, allowedIps = []) {
|
|
const normalizedRequestIp = normalizeIp(requestIp);
|
|
const normalizedAllowedIps = allowedIps.map(normalizeIp).filter(Boolean);
|
|
return normalizedAllowedIps.length === 0 || normalizedAllowedIps.includes(normalizedRequestIp);
|
|
}
|
|
|
|
function hashBody(rawBody) {
|
|
return crypto.createHash('sha256').update(String(rawBody || ''), 'utf8').digest('hex');
|
|
}
|
|
|
|
function buildCanonicalRequest({ timestamp, nonce, method, originalUrl, rawBody }) {
|
|
return [
|
|
String(timestamp || '').trim(),
|
|
String(nonce || '').trim(),
|
|
String(method || 'GET').toUpperCase(),
|
|
String(originalUrl || ''),
|
|
hashBody(rawBody),
|
|
].join('\n');
|
|
}
|
|
|
|
function signCanonicalRequest(apiKey, canonicalRequest) {
|
|
return crypto
|
|
.createHmac('sha256', String(apiKey || ''))
|
|
.update(String(canonicalRequest || ''), 'utf8')
|
|
.digest('hex');
|
|
}
|
|
|
|
function safeEqual(left, right) {
|
|
const leftBuffer = Buffer.from(String(left || ''), 'utf8');
|
|
const rightBuffer = Buffer.from(String(right || ''), 'utf8');
|
|
return leftBuffer.length === rightBuffer.length
|
|
&& crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
}
|
|
|
|
function cleanupExpiredNonces(nowMs) {
|
|
for (const [key, expiresAt] of usedNonces.entries()) {
|
|
if (expiresAt <= nowMs) usedNonces.delete(key);
|
|
}
|
|
}
|
|
|
|
function verifyHmacRequest(req, apiKey, keyId, options = {}) {
|
|
const timestamp = String(req.get('x-timestamp') || '').trim();
|
|
const nonce = String(req.get('x-nonce') || '').trim();
|
|
const providedSignature = String(req.get('x-signature') || '')
|
|
.trim()
|
|
.replace(/^sha256=/i, '')
|
|
.toLowerCase();
|
|
if (!timestamp || !nonce || !providedSignature) {
|
|
return { valid: false, reason: '缺少 X-Timestamp、X-Nonce 或 X-Signature' };
|
|
}
|
|
|
|
const timestampSeconds = Number(timestamp);
|
|
const nowMs = Number(options.nowMs || Date.now());
|
|
const toleranceSeconds = Number(options.toleranceSeconds || SIGNATURE_TOLERANCE_SECONDS);
|
|
if (!Number.isFinite(timestampSeconds)) {
|
|
return { valid: false, reason: 'X-Timestamp 必须是 Unix 秒级时间戳' };
|
|
}
|
|
if (Math.abs(Math.floor(nowMs / 1000) - timestampSeconds) > toleranceSeconds) {
|
|
return { valid: false, reason: '请求时间戳已过期' };
|
|
}
|
|
if (!/^[A-Za-z0-9_-]{8,160}$/.test(nonce)) {
|
|
return { valid: false, reason: 'X-Nonce 格式不正确' };
|
|
}
|
|
|
|
const rawBody = typeof req.rawBody === 'string'
|
|
? req.rawBody
|
|
: (req.body && Object.keys(req.body).length > 0 ? JSON.stringify(req.body) : '');
|
|
const canonicalRequest = buildCanonicalRequest({
|
|
timestamp,
|
|
nonce,
|
|
method: req.method,
|
|
originalUrl: req.originalUrl,
|
|
rawBody,
|
|
});
|
|
const expectedSignature = signCanonicalRequest(apiKey, canonicalRequest);
|
|
if (!safeEqual(providedSignature, expectedSignature)) {
|
|
return { valid: false, reason: 'HMAC 签名不匹配' };
|
|
}
|
|
|
|
cleanupExpiredNonces(nowMs);
|
|
const nonceKey = `${keyId || 'key'}:${nonce}`;
|
|
if (usedNonces.has(nonceKey)) {
|
|
return { valid: false, reason: 'X-Nonce 已使用,请勿重放请求' };
|
|
}
|
|
usedNonces.set(nonceKey, nowMs + toleranceSeconds * 1000);
|
|
return { valid: true, canonicalRequest };
|
|
}
|
|
|
|
module.exports = {
|
|
buildCanonicalRequest,
|
|
hashBody,
|
|
isRequestIpAllowed,
|
|
normalizeIp,
|
|
resolveRequestIp,
|
|
signCanonicalRequest,
|
|
verifyHmacRequest,
|
|
};
|