使用 Claude Code 进行 Webhook 处理:签名验证、幂等性和重试安全性

发布: (2026年3月11日 GMT+8 12:51)
5 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的具体内容(文本、段落或代码块),我将为您准确地翻译成简体中文,并保持原有的格式和技术术语不变。

Webhook 接收规则

安全(必需)

  • 验证 所有 传入 webhook 的签名(没有有效签名则拒绝)。
  • Stripe:使用 webhook secret 验证 stripe-signature 头部。
  • GitHub:使用 HMAC‑SHA256 验证 X-Hub-Signature-256
  • 关键:必须基于原始请求体进行验证(而不是已解析的 JSON —— 解析会改变字节)。

幂等性(必需)

  • 所有 webhook 处理程序必须具备幂等性(能够安全地接收同一事件两次)。
  • 幂等键格式:{provider}-{eventId},存储在 WebhookEvent 表中。
  • 已处理的事件:立即返回 200 并跳过后续处理。

响应时机

  • 5 秒 内返回 200(如果没有响应,提供方会重试)。
  • 重量级处理应在返回之前委派给队列(例如 BullMQ)。

重试安全性

  • 幂等性表可防止在重试时出现双重处理。
  • 让发送方自行处理其重试逻辑。

Stripe Webhook 实现

端点 (src/webhooks/stripeWebhook.ts)

import Stripe from 'stripe';
import express, { Router } from 'express';
import { prisma } from '../prisma'; // adjust import as needed
import { webhookQueue } from '../queues'; // BullMQ queue
import { logger } from '../logger';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export const stripeWebhookRouter = Router();

// IMPORTANT: Use express.raw() — not express.json()
// Stripe signature verification requires the raw request body
stripeWebhookRouter.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'] as string;

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
    } catch (err) {
      logger.warn({ err }, 'Stripe webhook signature failed');
      return res.status(400).send('Signature verification failed');
    }

    // Idempotency check
    const alreadyProcessed = await prisma.webhookEvent.findUnique({
      where: { id: `stripe-${event.id}` },
    });

    if (alreadyProcessed) {
      logger.info({ eventId: event.id }, 'Already processed, skipping');
      return res.status(200).json({ received: true });
    }

    // Record as processed (before processing to prevent race conditions)
    await prisma.webhookEvent.create({
      data: {
        id: `stripe-${event.id}`,
        type: event.type,
        processedAt: new Date(),
      },
    });

    // Return 200 immediately — don't block on processing
    res.status(200).json({ received: true });

    // Queue for async processing
    await webhookQueue.add(event.type, event);
  }
);

处理程序 (src/webhooks/handlers/paymentHandler.ts)

import Stripe from 'stripe';
import { prisma } from '../../prisma';
import { emailQueue } from '../../queues';
import { logger } from '../../logger';

export async function handlePaymentIntentSucceeded(
  event: Stripe.Event
): Promise {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  const orderId = paymentIntent.metadata.orderId;

  if (!orderId) {
    logger.warn(
      { paymentIntentId: paymentIntent.id },
      'No orderId in metadata'
    );
    return;
  }

  await prisma.order.update({
    where: { id: orderId },
    data: {
      status: 'completed',
      paidAt: new Date(),
      paymentIntentId: paymentIntent.id,
    },
  });

  await emailQueue.add('order-confirmation', { orderId });
  logger.info({ orderId }, 'Order completed via webhook');
}

export async function handlePaymentIntentFailed(
  event: Stripe.Event
): Promise {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  const orderId = paymentIntent.metadata.orderId;

  if (!orderId) {
    logger.warn(
      { paymentIntentId: paymentIntent.id },
      'No orderId in metadata'
    );
    return;
  }

  await prisma.order.update({
    where: { id: orderId },
    data: {
      status: 'failed',
      failedAt: new Date(),
    },
  });

  logger.info({ orderId }, 'Order marked as failed via webhook');
}

export async function handleSubscriptionDeleted(
  event: Stripe.Event
): Promise {
  const subscription = event.data.object as Stripe.Subscription;
  const customerId = subscription.customer as string;

  await prisma.subscription.updateMany({
    where: { stripeCustomerId: customerId },
    data: { active: false, cancelledAt: new Date() },
  });

  logger.info(
    { customerId },
    'Subscription deactivated via webhook'
  );
}

GitHub Webhook 实现 (src/webhooks/githubWebhook.ts)

import crypto from 'crypto';
import express, { Router } from 'express';
import { githubQueue } from '../queues';
import { logger } from '../logger';

const router = Router();

function verifyGitHubSignature(body: Buffer, signature: string): boolean {
  const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!);
  hmac.update(body);
  const expected = `sha256=${hmac.digest('hex')}`;

  // Timing‑safe comparison prevents timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false; // Different lengths
  }
}

router.post(
  '/webhooks/github',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-hub-signature-256'] as string;

    if (!signature || !verifyGitHubSignature(req.body, signature)) {
      logger.warn('Invalid GitHub webhook signature');
      return res.status(401).send('Invalid signature');
    }

    const event = req.headers['x-github-event'] as string;
    const payload = JSON.parse(req.body.toString());

    // Return fast, process async
    res.status(200).json({ received: true });

    await githubQueue.add(event, payload);
  }
);

export default router;

Webhook 事件模型(Prisma)

model WebhookEvent {
  id          String   @id // "{provider}-{eventId}"
  type        String
  processedAt DateTime

  @@index([processedAt])
}

维护

  • 使用计划的 cron 作业在 30 天 后清理旧事件。

有关 webhook 安全最佳实践的更多详细信息,请参阅指南。

0 浏览
Back to Blog

相关文章

阅读更多 »