requirePinnacleProxyAuth.js 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import crypto from 'node:crypto';
  2. import { safeEqual } from '../libs/auth.js';
  3. const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
  4. const usedNonces = new Map();
  5. const normalizePath = (path) => {
  6. if (!path || path === '/') {
  7. return '/';
  8. }
  9. return path.endsWith('/') ? path.slice(0, -1) : path;
  10. };
  11. const stableStringify = (value) => {
  12. if (Array.isArray(value)) {
  13. return `[${value.map(stableStringify).join(',')}]`;
  14. }
  15. if (value && typeof value === 'object') {
  16. return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
  17. }
  18. return JSON.stringify(value);
  19. };
  20. const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
  21. const getUsers = () => {
  22. return process.env.PINNACLE_PROXY_USERS?.split(';')
  23. .map(item => item.trim())
  24. .filter(Boolean)
  25. .map(item => {
  26. const [username, secret, permissions=''] = item.split(':');
  27. return {
  28. permissions: permissions.split(',').map(permission => normalizePath(permission.trim())).filter(Boolean),
  29. secret,
  30. username,
  31. };
  32. })
  33. .filter(user => user.username && user.secret) ?? [];
  34. };
  35. const getUser = (username) => getUsers().find(user => safeEqual(user.username, username));
  36. const cleanupUsedNonces = () => {
  37. const now = Date.now();
  38. usedNonces.forEach((expiresAt, key) => {
  39. if (expiresAt <= now) {
  40. usedNonces.delete(key);
  41. }
  42. });
  43. };
  44. const checkNonce = (username, nonce) => {
  45. cleanupUsedNonces();
  46. const key = `${username}:${nonce}`;
  47. if (usedNonces.has(key)) {
  48. return false;
  49. }
  50. usedNonces.set(key, Date.now() + MAX_CLOCK_SKEW_MS);
  51. return true;
  52. };
  53. const hasPermission = (user, path) => {
  54. const targetPath = normalizePath(path);
  55. return user.permissions.includes('*') || user.permissions.includes(targetPath);
  56. };
  57. const getBodyHash = (body) => {
  58. if (body === undefined || body === null || Object.keys(body).length === 0) {
  59. return sha256('');
  60. }
  61. return sha256(stableStringify(body));
  62. };
  63. const buildSigningPayload = (req, timestamp, nonce) => [
  64. req.method.toUpperCase(),
  65. normalizePath(req.path),
  66. stableStringify(req.query ?? {}),
  67. getBodyHash(req.body),
  68. timestamp,
  69. nonce,
  70. ].join('\n');
  71. const requirePinnacleProxyAuth = (req, res, next) => {
  72. const username = req.get('x-api-user');
  73. const timestamp = req.get('x-api-timestamp');
  74. const nonce = req.get('x-api-nonce');
  75. const signature = req.get('x-api-signature');
  76. if (!username || !timestamp || !nonce || !signature) {
  77. return res.unauthorized('Missing API signature headers');
  78. }
  79. const requestTime = Number(timestamp);
  80. if (!Number.isFinite(requestTime) || Math.abs(Date.now() - requestTime) > MAX_CLOCK_SKEW_MS) {
  81. return res.unauthorized('Invalid API timestamp');
  82. }
  83. const user = getUser(username);
  84. if (!user) {
  85. return res.unauthorized('Invalid API user');
  86. }
  87. if (!hasPermission(user, req.path)) {
  88. return res.status(403).json({ statusCode: 403, code: -1, message: 'Forbidden' });
  89. }
  90. const payload = buildSigningPayload(req, timestamp, nonce);
  91. const expectedSignature = crypto.createHmac('sha256', user.secret).update(payload).digest('hex');
  92. if (!safeEqual(signature, expectedSignature)) {
  93. return res.unauthorized('Invalid API signature');
  94. }
  95. if (!checkNonce(username, nonce)) {
  96. return res.unauthorized('API nonce has already been used');
  97. }
  98. req.pinnacleProxyUser = { username };
  99. return next();
  100. };
  101. export default requirePinnacleProxyAuth;