見出し画像

【前編】StripeのWebhookエンドポイントをAPI Gateway→Lambda→Kinesisのserverless(サーバーレス)で構築し、ローカル環境での検証もできるようにしてみた

はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

Stripe を決済代行業者として利用して課金決済のアプリケーション・基盤を構築する際、Stripe からの Webhook を受け取り、何らかの処理をしたい場面がある。

今回は Stripe の Webhook を API Gateway で作成し、API Gateway から Lambda→Kinesis とデータを流すサーバレスアプリケーションの開発をやってみたいと思う。その際、serverless-offline とその plugin を利用し、SQS→Lambda→SES のメール送信をローカル環境で検証できるようにするで取り上げたように、ローカルの開発環境で検証もできるようにしてみる。

なお、今回は記事の長さの都合上、前編・後編に記事を分けている。
前編では API Gateway→Lambda の部分を、後編では Lambda→Kinesis の部分と通しでの確認の部分を、それぞれ取り上げる。

※今回は API Gateway→Lambda→Kinesis という構成を取った。
ただ、他の選択として Web アプリケーションのサーバー側に Webhook のエンドポイントを構築する(例えば Node.js Express のエンドポイントとして構築する)もあるだろう。ただその場合、以下のような懸念事項や実現したい事がある場合には不利なので、Webhook はそれ単独で動くようにサービスを構築するのがいいだろうという判断。

  • Webhook に Web アプリケーションが影響を受ける構成になり、最悪の場合は Webhook 起因でサーバーが落ち、アプリケーション全体に影響が出る事態になりかねない

  • マイクロサービスアーキテクチャを採用しており、Stripe の Webhook イベントに基づいてデータ更新を複数のマイクロサービスで行いたい場合、何らかの方法で各マイクロサービスにイベントを伝搬させないといけない(Kinesis を利用すれば、複数の Consumer を設定できるので、Webhook の受け手は単に Producer として Kinesis にデータを流せばいい)

※今回の検証では以下のバージョンを使用している

  • Node.js:16.17.0

  • serverless:3.25.1

  • serverless-offline:12.0.3

  • serverless-offline-kinesis:6.2.3

Stripe の Webhook をローカルで検証するための準備

Stripe CLI で Webhook の組み込みをテストするに書かれている通り、
Stripe CLI をインストールし、stripe listen コマンドを実行する事で、簡単に Webhook を受け取る事ができる。

まずは、Stripe CLI を使ってみるに沿って Stripe CLI をインストールする。

study@localhost:~/workspace/learn-serverless (stripe-webhooks +)
$ su -
パスワード:
最終ログイン: 2022/11/02 (水) 15:38:49 JST日時 tty1
[root@localhost ~]# echo -e "[Stripe]\nname=stripe\nbaseurl=https://packages.stripe.dev/stripe-cli-rpm-local/\nenabled=1\ngpgcheck=0" >> /etc/yum.repos.d/stripe.repo
[root@localhost ~]# yum install -y stripe
...

[root@localhost ~]# exit
ログアウト
study@localhost:~/workspace/learn-serverless (stripe-webhooks +)
$ stripe --version
stripe version 1.13.6

あとは以下のようにエンドポイントになる URL を--forward-to(-f)オプションに渡せば、webhook を受け取れるようになる。

study@localhost:~/workspace/learn-serverless (stripe-webhooks +)
$ stripe listen -f localhost:3000/webhooks
> Ready! You are using Stripe API Version [2022-11-15]. Your webhook signing secret is whsec_******************************************************* (^C to quit)

※初回の stripe listen コマンド実行時には、stripe へ CLI に対する認可を行う必要がある。その場合には、以下のように認可を行うための URL が CLI 上に表示されるので、そこにアクセスして「アクセスを許可」をクリックすれば認可が完了し、Stripe CLI が利用できるようになる。

study@localhost:~/workspace/learn-serverless (stripe-webhooks +)
$ stripe listen -f localhost:3000/webhook
You have not configured API keys yet. Running `stripe login`...
Your pairing code is: solid-admire-excite-daring
This pairing code verifies your authentication with Stripe.
To authenticate with Stripe, please go to: https://dashboard.stripe.com/stripecli/confirm_auth?t=Rgy3************************
> Done! The Stripe CLI is configured for your account with account id acct_**********

Please note: this key will expire after 90 days, at which point you'll need to re-authenticate.

API Gateway→Lambda の部分を構築する

サーバーレスの開発は大きく分けて以下の 2 つになる。

①API Gateway→Lambda の部分
②Lambda→Kinesis の部分

① では、Lambda Authorizers によるリクエストのバリデーションを行う Lambda の実装と、バリデーションチェック後に API Gateway から受け取ったイベントデータで Stripe の署名確認を行い、Kinesis に流すデータを生成する Lambda の実装、の 2 つに分けて実装する。

前編ではここまでを扱う。

Lambda Authorizers によるリクエストのバリデーション

AWS Lambda Eventsにあるように、events というキーを設定しイベントソースマッピングを作るが、その際に Lambda Authorizers を設定できる。Lambda Authorizers は、ビジネスロジックを実行する前に何らかの認証などの前処理を行いたい場合に利用できる便利な機能。

※ちなみに、API Gateway はv1v2があるが、今回は v1 を利用する
(v1 を選定した理由に関しては「おまけ」の「API Gateway v1 の選定理由」を参照)。

まずは serverless.yaml の方の設定を行う。以下のように events に http を設定し、あとはHTTP Endpoints with Custom Authorizersにあるように、Lambda Authorizers に指定したい関数を追加で設定すればいい(この後出てくるが、AWS SSM のパラメータストアを利用するため、その IAM ロールも設定している)。

# serverless.yaml
# 省略

provider:
  # 省略
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - ssm:GetParameter
            - ssm:GetParameters
          Resource:
            - !Sub arn:aws:ssm:ap-northeast-1:${AWS::AccountId}:parameters${file(./env/${self:provider.stage}.json):SSM_PARAMETER_NAME_STRIPE_SECRET_KEY}
            - !Sub arn:aws:ssm:ap-northeast-1:${AWS::AccountId}:parameters${file(./env/${self:provider.stage}.json):SSM_PARAMETER_NAME_STRIPE_ENDPOINT_SECRET}

# 省略
functions:
  stripe2kinesis:
    name: stripe2kinesis-${sls:stage}
    handler: src/stripe2kinesis.handler
    timeout: 5
    environment:
      STAGE: ${self:provider.stage}
      REGION: ${self:provider.region}
    events:
      - http:
          method: post
          path: /webhook
          authorizer:
            name: authorizerFunc
            identitySource: method.request.header.Stripe-Signature
            type: request
  authorizerFunc:
    name: authorizerFunc-${sls:stage}
    handler: src/authorizerFunc.handler

1 点ポイントとして、Custom authorizersに書かれている通り、identitySource を設定する事でカスタムでヘッダーのどのキーを authorizationToken にマッピングするのか?を設定できるが、今回はデフォルトの"method.request.header.Authorization"では以下のエラーになるため、Stripe に合わせて設定する必要がある。Stripe の場合、いわゆる通常のトークンではないが、確認すべきヘッダーのキーという意味では署名を手動で検証するに書かれている通り、"Stripe-Signature"になるためそれを設定する(これにより Stripe-Signature をヘッダーに含まないリクエストはエラーになる)。

The plugin only supports retrieving Tokens from headers.

Lambda Authorizers の関数の中の実装としては以下のように実装してみた。ここではヘッダーの Stripe-Signature からtimestampを取り出し、それが現在時刻から過去にさかのぼって 5 分以内でなければ Deny にするという事をしている(これはリプレイ攻撃を防止するに書かれている対策に則っている)。

// src/authorizerFunc.js
import { DateTime } from "luxon";
import { createLogger, format, transports } from "winston";

// 省略
// eslint-disable-next-line import/prefer-default-export
export const handler = async (event) => {
  const response = {
    principalId: "stripe",
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: "Allow",
          Resource: event.methodArn,
        },
      ],
    },
  };

  try {
    const stripeSignature = event.headers[`Stripe-Signature`];

    if (!stripeSignature || !stripeSignature.split(",").length) {
      response.policyDocument.Statement[0].Effect = "Deny";
      return response;
    }

    const regex = /^t=([0-9]{10})$/;
    const timestamp = stripeSignature.split(",").shift().match(regex)[1];
    const time5MinutesAgo = DateTime.now()
      .minus({ minutes: 5 })
      .toUnixInteger();

    if (!(time5MinutesAgo < Number(timestamp))) {
      response.policyDocument.Statement[0].Effect = "Deny";
      return response;
    }

    return response;
  } catch (e) {
    logger.error({ message: e.message, stack: e.stack });

    response.policyDocument.Statement[0].Effect = "Deny";
    return response;
  }
};

API Gateway からのリクエストを受け取り、Kinesis に流すデータを取り出す

続いて本体の Lmabda(stripe2kinesis)の方も実装する。
ここでは Stripe の Node.js ライブラリを利用して署名確認を行う部分までを実装する(Kinesis にデータを送る部分は次の「②Lambda→Kinesis の部分を構築する」の章で取り上げる)。

実装方法はStripe 公式ライブラリを使用して署名を検証するに書かれている通りで、以下のようになるだろう(ひとまず、署名検証を行い、その結果戻る値を logging するまでを実装している)。

// src/lib/ssm-get-parameters.js
// 省略
import { SSMClient, GetParametersCommand } from "@aws-sdk/client-ssm";

export default async (options = {}) => {
  // 省略
  const {
    stage,
    region,
    ssmParamNameStripeSecretKey,
    ssmParamNameEndpointSecret,
  } = options;
  const isLocal = stage === "local";

  const ssmClient = new SSMClient(
    isLocal ? { region, endpoint: "http://localhost:4566" } : { region }
  );
  const { Parameters: params } = await ssmClient.send(
    new GetParametersCommand({
      Names: [ssmParamNameStripeSecretKey, ssmParamNameEndpointSecret],
      WithDecryption: true,
    })
  );

  return {
    stripeSecretKey: params.find(
      (param) => param.Name === ssmParamNameStripeSecretKey
    ).Value,
    endpointSecret: params.find(
      (param) => param.Name === ssmParamNameEndpointSecret
    ).Value,
  };
};
// src/stripe2kinesis.js
import getSsmParameters from "@/get-ssm-parameters"; // @は"src/lib/*"のエイリアス
// 省略

// eslint-disable-next-line import/prefer-default-export
export const handler = async (event) => {
  try {
    const sig = event.headers[`Stripe-Signature`];
    const {
      STAGE: stage,
      REGION: region,
      STRIPE_SECRET_KEY: stripeSecretKey,
      ENDPOINT_SECRET: endpointSecret,
    } = process.env;
    // 省略
    const { stripeSecretKey, endpointSecret } = getSsmParameters({
      stage,
      region,
      ssmParamNameStripeSecretKey,
      ssmParamNameEndpointSecret,
    });
    const stripe = new Stripe(stripeSecretKey);

    const {
      type,
      data: { object: payload },
    } = stripe.webhooks.constructEvent(event.body, sig, endpointSecret);

    logger.info({ type, payload });
    return { status: 202 };
  } catch (e) {
    logger.error({ message: e.message, stack: e.stack });
    return { status: 500 };
  }
};

1 点補足として、上記では AWS SSM からパラメータを取得している部分があるが、これは今回、秘密のキーとして以下の 2 つが必要になるので、それを AWS SSM のパラメータストアに設定しているため(環境変数などだと限られた人以外もキーを閲覧でき良くない)。

① Stripe のライブラリの初期化時のAPI キー
② 署名確認時のエンドポイントシークレット(ローカルの場合、上記の「Stripe の Webhook をローカルで検証するための準備」の章で取り上げた手順で発行される)

※AWS SSM のパラメータストアの設定に関しては、サーバーレスでは管理しない想定
※AWS SSM を利用するにあたり、ローカル環境での検証・テストには SSM のエミュレーターが必要だが、それについてはserverless-offline とその plugin を利用し、SQS→Lambda→SES のメール送信をローカル環境で検証できるようにするで取り上げた Localstack を利用する。

上記のように実装した後、Stripe CLI で Webhook の組み込みをテストするに書かれている手順で Stripe の Webhook イベントを作成し、ローカルの開発環境で検証を行ってみると、以下のようにイベントのデータがログに取得され、API Gateway→Lambda の部分の構築ができている事が確認できる。

ここまでで「①API Gateway→Lambda の部分を構築する」が完了となる。

※今回、Lambda Authorizers 側で Stripe の Webhook の署名確認の一部(リクエストヘッダーに Stripe-Signature が含まれているか?ヘッダーの Stripe-Signature の値の timestamp は古すぎないか?)を行ったが、stripe2kinesis の中で Stripe のライブラリを利用して署名確認している部分と処理としては被っているので、全部 stripe2kinesis の Lambda 内で行うという判断もあるだろう。今回は Lambda Authorizers を利用しての実装を検証してみたかったという事もあり、あえて Lambda Authorizers を使った実装をしている。

まとめとして

今回は Stripe のの Webhook エンドポイントとして、API Gateway→Lambda→Kinesis のサーバーレスアプリケーションの構築をやってみるにあたり、前編として API Gateway→Lambda の部分の実装をやってみた。

後編では、Lambda→Kinesis の部分の実装を行い、ローカルの開発環境で通し(API Gateway→Lambda→Kinesis)で検証するという事もやってみたいと思う。

おまけ

API Gateway v1 の選定理由

  • コストの視点
    v2 の方が料金体系としては v1 に比べ安いが、Stripe の Webhook がどれだけ想定されるかでは、料金の差が誤差になり決定的に v2 でなればならないという事はない
     → 仮に計算として、Stripe の決済が 100/日・件として、Webhook がその 10 倍と多めに想定しても、月で言うと 100 件 ×10 倍 ×30 日= 3 万リクエスト。バッファで 2 倍としても 6 万リクエストなので、API Gateway の料金体系 を見ても、誤差と見なせるので、v2 にしなければいけない決定的な理由にコストが上がらないという考え方。

  • 機能の視点
    v1 の方が WAF を利用したり、セキュリティのためにできる事(オプション機能)があるが、v2 はシンプルな機能でできない事もある

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
認証認可(SHIFTアカウント)や課金決済のプラットフォーム開発に従事。リードエンジニア。
経歴としては、SaaS ERPパッケージベンダーにて開発を2年経験。
SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。その後現在のプラットフォーム開発に参画(1年半)。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/