Cloudflare TurnstileとStripeを組み合わせて、botアクセス・カードテスティングを予防する方法

オンライン決済では、不正利用への対策が常に求められます。

2022年のStripeによる調査では、この2年でクレジットカードの不正利用が増加し、より巧妙化していることが判明しました。

![スクリーンショット 2023-09-19 10.27.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/1a6aa09c-c655-f648-e22d-14ed8d2f970b.png)
https://stripe.com/jp/guides/state-of-online-fraud

その中でも、調査対象者の40％以上が、「カードテスティング攻撃」を受けたことがあると回答しています。カードテスティングは、盗難カードの情報が有効で買い物に使用できるかどうかを確かめるために行われます。不正使用者は盗難クレジットカードの情報を購入し、それらのカードを使って認証や購入処理を行い、どのカードが現在も有効であるかを判断します。

## カードテスティングによるビジネスへの影響

カードテスティングを受けると、「不審請求の請求（チャージバック申請）」が増加する恐れがあります。実際に支払いが発生するカードフォームでテスティングが行われた場合、支払いが成功し、本来のカード所有者に対して請求が行われます。カード所有者が見覚えのない請求に気づくと、カード会社に対してチャージバックの申請を行い、それによってStripeアカウントオーナーは不審請求への対応や返金処理、そして追加の手数料支払いなどが発生します。

https://stripe.com/docs/disputes/how-disputes-work

また、カードテスティングによって所有するStripeアカウントでの「支払い拒否率」が高まると、カード発行会社とカードネットワークでのビジネスの評価の低下につながります。また、不審請求の請求件数が一定の割合を超えると、カードネットワークスごとに運用されるモニタリングプログラムに登録されます。モニタリングプログラムに登録されると、不審請求の申請や不正使用のレベルが持続的に下がるまで、月ごとに反則金と追加手数料が発生する場合があります。

https://stripe.com/docs/disputes/monitoring-programs

この他にも、botによるカードテスティング行為が集中することで、インフラへの負荷が高まるリスクなども存在します。

## Stripeでのカードテスティング対策

StripeではCheckoutやPayment Links、Elementsを利用している場合、疑わしい支払いに対して自動的に[CAPTCHA](https://www.hcaptcha.com/)を自動実行します。

https://stripe.com/docs/disputes/prevention/card-testing?locale=id-ID#optimize-integration

また、決済に対して以下の情報を含めることで、カードテスティングモデルの性能に大きな影響を与えることができます。

- [Stripe SDKの高度な不正使用検出機能](https://stripe.com/docs/disputes/prevention/advanced-fraud-detection)
- IPアドレス
- 顧客のメールアドレス
- 顧客の名前
- 請求先住所

### Stripe Checkoutで追加情報を集める方法
Stripe Checkoutでは、デフォルトで氏名とメールアドレスの入力欄が用意されています。

請求先住所についても、`billing_address_collection`を`required`に設定することで、入力欄を追加できます。

```diff_javascript
    await stripe.checkout.sessions.create({
+        billing_address_collection: 'required',
        line_items: [{
            price: 'price_xxxx',
            quantity: 1,
        }],
        success_url: 'https://example.com',
        mode: 'payment'
    })
```

![スクリーンショット 2023-09-19 11.00.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/461f5ffe-ff5a-bb62-c827-17af3cb137a3.png)


### Stripe Elementsで追加情報を集める方法

Stripe Elementsでは、`AddressElement`を利用します。

```diff_html
<form id="payment-form">
    <div id="payment-element"></div>
+    <div id="address-element"></div>
    <button type="submit">Sign in</button>
</form>
<script src="https://js.stripe.com/v3/"></script>
<script>

const stripe = Stripe('pk_test_から始まるStripe公開可能キーを設定する');
const elementsAppearance = {
    theme: 'light'
};
const options = {
    mode: 'payment',
    amount: 1000,
    currency: 'jpy',
    appearance: elementsAppearance,
};
const elements = stripe.elements(options);
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
+const addressElement = elements.create('address', {
+    mode: 'billing',
+    appearance: elementsAppearance,
+});
+addressElement.mount('#address-element');
</script>
```

これによって、氏名や請求先住所を決済情報に含めることができます。

![スクリーンショット 2023-09-19 10.57.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/c0c1a40b-2db4-1f84-9877-7b39ab98c955.png)

## Cloudflare Turnstileを利用した、「追加のカードテスティング対策」

Stripeが提供するカードテスティング対策に追加して、botからのアクセスをブロックしたい場合、[Cloudflare Turnstile](https://www.cloudflare.com/ja-jp/products/turnstile/)を追加することもできます。


![スクリーンショット 2023-09-19 12.07.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/d133a30d-2b4e-d104-aebd-6f0d00c04904.png)


Cloudflareが提供するコードスニペットを追加し、決済処理を実行する前にブロックしたり、3Dセキュア認証の要求や支払いのレビュー対象にすることができます。

## Cloudflare Turnstileと、Stripe Elementsを連携させた決済フォームを作る方法

ここからは、Cloudflare TurnstileとStripe Elementsを連携させる方法を紹介します。

### Honoでアプリをセットアップする

JavaScriptを利用したAPIとフロントエンドを作るため、[Hono](https://hono.dev)を利用してセットアップを行います。

```bash
$ npm create hono@latest stripe-turnstile
```

「どのプラットフォームで利用するか」を聞かれます。ローカル環境とデプロイを効率化するためのツール「[Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)」を利用するため、`cloudflare-workers`を選択しましょう。

```
create-hono version 0.2.6
? Which template do you want to use? › - Use arrow-keys. Return to submit.
  ↑ cloudflare-pages
❯   cloudflare-workers
    deno
    fastly
    lagon
    lambda-edge
    netlify
    nextjs
    nodejs
    vercel
```

以下のメッセージが表示されれば、セットアップ完了です。

```bash
✔ Which template do you want to use? › cloudflare-workers
cloned honojs/starter#main to /Users/sandbox/stripe-turnstile
✔ Copied project files
```

### WranglerでCloudflareアカウントと接続する

ここからは[Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/)を利用してローカル開発を行います。

CLIコマンドを、npmでインストールしましょう。

```bash
% npm install wrangler --save-dev
```

その後、`wrangler login`コマンドでCloudflareアカウントにログインします。

```bash
% wrangler login
```

これでローカル開発とデプロイの準備ができました。

### 決済フォームを表示する

続いてStripe Elementsを利用した決済フォームを作成します。

#### JSXに対応したファイル拡張子に変更する

JSXを利用してHTMLを動的に作成しますので、`src/index.ts`を`src/index.tsx`に変更しましょう。

```
mv src/index.ts src/index.tsx
```

同時に、`package.json`の`scripts`に指定されているファイル名も変更しましょう。

```diff

  "scripts": {
-    "dev": "wrangler dev src/index.ts",
-    "deploy": "wrangler deploy --minify src/index.ts"
+    "dev": "wrangler dev src/index.tsx",
+    "deploy": "wrangler deploy --minify src/index.tsx"
  },
```

#### StripeのAPIキーと、Turnstileのキーを環境変数に登録しよう

StripeとCloudflare Turnstileそれぞれを利用するため、APIキーを環境変数に登録しましょう。

Cloudflare Turnstileは、テスト用のサイトキーとシークレットキーをドキュメントから取得できます。

https://developers.cloudflare.com/turnstile/reference/testing/

Stripeの公開可能キーとシークレットキーは、Stripeダッシュボードから取得しましょう。

https://dashboard.stripe.com/test/apikeys

```env:.dev.vars
TURNSTILE_SITE_KEY = '1x00000000000000000000AA'
TURNSTILE_SECRET_KEY = '1x0000000000000000000000000000000AA'
STRIPE_PUBLISHABLE_KEY='pk_test_から始まる公開可能キー'
STRIPE_SECRET_KEY='sk_test_から始まるシークレットキー'
```

HonoとWranglerを利用したローカル開発では、`.dev.vars`に設定した値を、次のような書き方で取得できます。

```ts
app.get('/hello', async c => {
    console.log(c.env.TURNSTILE_SITE_KEY);
    return c.json({ message: 'test' });
})
```

#### HTMLを作成しよう

続いて決済フォームを表示するコードを実装しましょう。

`src/index.tsx`のコードを、次のように変更します。

```tsx:src/index.tsx
import { Hono } from 'hono';
import { FC } from 'hono/jsx';
import { html } from 'hono/html';

type Bindings = {
     TURNSTILE_SITE_KEY: string;
     TURNSTILE_SECRET_KEY: string;
     STRIPE_PUBLISHABLE_KEY: string;
     STRIPE_SECRET_KEY: string;
};
const app = new Hono<{
    Bindings: Bindings
}>();

const Top:FC<{
    TURNSTILE_SITE_KEY: string;
    STRIPE_PUBLISHABLE_KEY: string;
}> = ({TURNSTILE_SITE_KEY: siteKey, STRIPE_PUBLISHABLE_KEY: stripePublishableKey}) => {
    return (
        <html>
            <head>
                <title>決済フォームデモ</title>
                <style>
                    {html`
                html,
                body {
                    height: 100%;
                }

                body {
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    padding-top: 40px;
                    padding-bottom: 40px;
                    background-color: #fefefe;
                }
                form > * {
                    margin-bottom: 20px;
                }
                    `}
                </style>
            </head>
            <body>
                <form id="payment-form">
                    <div id="payment-element"></div>
                    <div id="address-element"></div>
                    <div id="result"></div>
                    <button type="submit">Order</button>
                </form>
                {html`
                    <script src="https://js.stripe.com/v3/"></script>
                    <script>
                    const stripe = Stripe('${stripePublishableKey}');
                    const elementsAppearance = {
                       theme: 'stripe'
                    };
                    const options = {
                        mode: 'payment',
                        amount: 1000,
                        currency: 'jpy',
                        appearance: elementsAppearance
                    };
                    const elements = stripe.elements(options);
                    const paymentElement = elements.create('payment');
                    paymentElement.mount('#payment-element');
                    const addressElement = elements.create('address', {
                        mode: 'billing',
                        appearance: elementsAppearance,
                    });
                    addressElement.mount('#address-element');
                    </script>
                    `}
            </body>
        </html>
    );
};

app.get('/', (c) => {
    return c.html(<Top
            STRIPE_PUBLISHABLE_KEY={c.env.STRIPE_PUBLISHABLE_KEY}
            TURNSTILE_SITE_KEY={c.env.TURNSTILE_SITE_KEY}
    />)
});

export default app;
```

このコードでは、決済ページのHTMLを、`Top`コンポーネントとしてJSXで定義しています。また、定義したJSXは`c.html()`でレンダリングし、決済フォームの描画に必要なStripeの公開可能キーを環境変数から受け取っています。

#### Wranglerで決済フォームを表示する

実装した決済フォームの表示を確認しましょう。

`npm run dev`を実行すると、Wrangler経由でアプリが立ち上がります。

```bash
$ npm run dev
[mf:inf] Updated and ready on http://0.0.0.0:8787 
[mf:inf] - http://127.0.0.1:8787
[mf:inf] - http://192.168.86.21:8787
[mf:inf] - http://172.18.144.83:8787
```

`http://127.0.0.1:8787`にアクセスすると、Stripeの決済フォームが表示されます。

![スクリーンショット 2023-09-19 12.47.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/747f4ac0-3a89-a7c3-49d7-ff4b94e209df.png)

### Stripe Payment Intentで、決済処理を実行する

続いて決済処理を実装しましょう。

決済処理を行うために必要なPayment Intentを作成するので、Stripe SDKをインストールします。

```bash
% npm i stripe
```

続いて`src/index.tsx`に`POST /payment-intent`APIを追加しましょう。

```diff_tsx:src/index.tsx
+import Stripe from 'stripe';

...

+app.post('/payment-intent', async c => {
+    const stripe = new Stripe(c.env.STRIPE_SECRET_KEY, {
+        apiVersion: '2023-08-16',
+        appInfo: {
+            name: 'qiita-example/cloudflare-turnstile-example'
+        }
+    });
+    const paymentIntent = await stripe.paymentIntents.create({
+        amount: 1000,
+        currency: 'jpy'
+    });
+    return c.json({
+        client_secret: paymentIntent.client_secret
+    });
+});
```

ここでは、1,000円の支払いを行うための、Payment Intentを作成し、クライアント用のシークレットをレスポンスとして返すAPIを追加しています。

このAPIを利用して、決済処理を行うコードをフロントエンド側に追加しましょう。

```diff_tsx:src/index.tsx
+                        let submitButon = document.querySelector("button[type='submit']");
+                        const resultElement = document.getElementById('result');

...
                        paymentElement.mount('#payment-element')
+                        const paymentForm = document.getElementById('payment-form')
+                        paymentForm.addEventListener('submit', async e => {
+                            e.preventDefault();
+                            if (submitButon) {
+                                submitButon.setAttribute('disabled', true);
+                            }
+                            const { error: submitError } = await elements.submit();
+                            if (submitError) {
+                                console.log(submitError);
+                                submitButon.removeAttribute('disabled');
+                                return;
+                            }
+                            const response = await fetch('/payment-intent', {
+                                method: "POST",
+                            })
+                            const { client_secret: clientSecret } = await response.json();
+                            const { error: confirmationError } = await stripe.confirmPayment({
+                                elements,
+                                clientSecret,
+                                confirmParams: {
+                                    return_url: 'http://localhost:8787'
+                                }
+                            });
+                            submitButon.removeAttribute('disabled');
+                            console.log(confirmationError);
+                            resultElement.innerHTML = JSON.stringify(confirmationError, null, 2);
+                        })
```

Stripeが提供する[テスト用カード番号](https://stripe.com/docs/testing?locale=ja-JP)を入力することで、支払い処理の成功や、カードのエラー処理などが確認できます。

![スクリーンショット 2023-09-19 13.02.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/b91d9325-7cd5-60ee-f06a-cfb713332e3e.png)

### Cloudflare Turnstileで、botによるフォーム操作をブロックする

Cloudflare Turnstileを利用する場合、「Turnstileの検証に通過した場合のみ注文できるようにする」実装が行えます。

これによって、botによるPayment Intentを作成するAPI呼び出し数を減らすことができます。

`src/index.tsx`に、Turnstile用のHTMLを追加しましょう。

```diff_tsx:src/index.tsx
                <form id="payment-form">
                    <div id="payment-element"></div>
                    <div id="address-element"></div>
                    <div id="result"></div>
+                    <div class="cf-turnstile" data-sitekey={`${siteKey}`}></div>
                    <button type="submit">Order</button>
                </form>
                {html`
+                    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=_turnstileCb" async defer></script>
                    <script src="https://js.stripe.com/v3/"></script>
```

続いて、Stripe Elementsの決済処理に、Turnstileを組み込みます。

```diff_tsx:src/idnex.tsx
-                    <button type="submit">Order</button>
+                    <button type="submit" disabled>Order</button>
                </form>
                {html`
                    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=_turnstileCb" async defer></script>
                    <script src="https://js.stripe.com/v3/"></script>
                    <script>
+                        let turnstileToken = '';
                        let submitButon = document.querySelector("button[type='submit']");
+                        function _turnstileCb() {
+                            turnstile.render('.cf-turnstile', {
+                                callback: function(token) {
+                                    turnstileToken = token;
+                                    submitButon.removeAttribute('disabled');
+                                },
+                            })
+                        }

```

Turnstileの認証結果を`turnstile.render`の`callback`で受け取り、成功した場合のみ`submit`ボタンの`disabled`属性を削除しています。

このため、Turnstileの検証に失敗した場合は、フォームのボタンを押すことができません。


![スクリーンショット 2023-09-19 13.14.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/1bca2654-8d54-4d19-8c41-135d283a4a00.png)

フォームのsubmit処理を次のように変更することで、submit処理側でもブロックできます。

```diff_tsx:src/index.tsx

                        paymentForm.addEventListener('submit', async e => {
                            e.preventDefault();
+                            if (!turnstileToken) return;
                            if (submitButon) {
```


#### Cloudflare Turnstileによるサーバー側の検証も実施する

Cloudflare Turnstileでは、フロントエンドでの認証結果を使って、サーバー側で検証を行うことができます。
`POST /payment-intent`APIを編集して、こちらでも検証を行えるようにしましょう。

```diff_tsx:src/index.tsx
+import { HTTPException } from 'hono/http-exception';

...

app.post('/payment-intent', async c => {
+    const body = await c.req.json();
+    const ip = c.req.header('CF-Connecting-IP')

+    const formData = new FormData();
+    formData.append('secret', c.env.TURNSTILE_SECRET_KEY);
+    formData.append('response', body.turnstileToken);
+    formData.append('remoteip', ip || '');
+    const turnstileResult = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
+        body: formData,
+        method: 'POST',
+    });
+    const outcome = await turnstileResult.json<TurnstileResult>();
+    if (!outcome.success) {
+        throw new HTTPException(401, {
+            message: JSON.stringify(outcome)
+        });
+    }
    const clientSecret = ''
    return c.json({
        client_secret: clientSecret
    })
})

export default app
```

フロントエンドアプリのコードを変更し、クライアント側のTurnstileで取得したトークンをAPIに送信しましょう。

```diff_tsx:src/index.tsx
                            const response = await fetch('/payment-intent', {
                                method: "POST",
+                                body: JSON.stringify({
+                                    turnstileToken,
+                                })
                            })

```


環境変数の`TURNSTILE_SECRET_KEY`を、「常に失敗するキー」に変更しましょう。

```diff:.dev.vars
-TURNSTILE_SECRET_KEY = '1x0000000000000000000000000000000AA'
+TURNSTILE_SECRET_KEY = '2x0000000000000000000000000000000AA'
```

`npm run dev`を実行し直して、決済フォームを操作すると、下の画像のようなエラーが発生します。

![スクリーンショット 2023-09-19 13.31.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/1364063b-6660-7962-1192-5cb15bcd2e7c.png)

このようにして、Stripe側のチェックシステムに加えて、Cloudflareのbot対策機能を決済フローに組み込むことができます。

## Turnstileの検証結果を、3DセキュアやStripe Radarの判定に利用する方法

「Turnstileの結果を元に決済をブロックする」代わりに、Stripe側で追加の確認を行うフローにすることもできます。

まず、フロントエンドアプリのブロック処理を削除しましょう。

```diff_tsx:src/index.tsx
-                    <button class="w-100 btn btn-lg btn-primary" type="submit" disabled>Sign in</button>
+                    <button class="w-100 btn btn-lg btn-primary" type="submit" >Sign in</button>

...
                        paymentForm.addEventListener('submit', async e => {
                            e.preventDefault();
-                            if (!turnstileToken) return;
                            if (submitButon) {
                                submitButon.setAttribute('disabled', true);
                            }
```

続いて`POST /payment-intent`の処理を次のように変更します。

```diff_tsx:src/index.tsx
    const outcome = await turnstileResult.json<TurnstileResult>();
    
-    if (!outcome.success) {
-        throw new HTTPException(401, {
-            message: JSON.stringify(outcome)
-        });
-    }
    
    const stripe = new Stripe(c.env.STRIPE_SECRET_KEY, {

...

    const paymentIntent = await stripe.paymentIntents.create({
        amount: 1000,
        currency: 'jpy',
+        metadata: {
+            turnstile_result: outcome.success ? 'success' : 'failed',
+        },
+        payment_method_options: {
+            card: {
+                request_three_d_secure: outcome.success ? 'automatic' : 'any',
+            }
+        }
    });
```
`payment_method_options[card][request_three_d_secure]`を`any`に変更することで、顧客に3Dセキュアによる追加認証を手動で要求することができます。

![スクリーンショット 2023-09-19 14.25.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/72281871-e18c-2275-b7c2-2637b3f2a1b7.png)

また、[Stripe Radar for Teams]()を利用されている場合は、ルールを作成することで、「レビューが必要な決済」としてマークすることもできます。

![スクリーンショット 2023-09-19 14.22.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/eed1cf3a-200b-6c78-4f56-95a2fb2367f0.png)

レビューリストに支払いを追加することで、Cloudflare Turnstileが「botなどからの決済である可能性が高い」と判断した支払いに対して手動でレビューを行うことができます。

https://stripe.com/docs/radar/reviews?locale=ja-JP

また、`outcome.challenge_ts`を利用して、「いつ認証が成功したか」をmetadataに含めることもできます。

```diff_tsx:src/index.tsx
    const paymentIntent = await stripe.paymentIntents.create({
        amount: 1000,
        currency: 'jpy',
        metadata: {
            turnstile_result: outcome.success ? 'success' : 'failed',
+            turnstile_challenge_ts: outcome.challenge_ts,
        }
    });

```

これらのデータを活用して、オンライン決済をより安全に顧客へ提供することができます。

## 追加の学習リソース

StripeとCloudflare Turnstileを利用した不正決済・botアクセス対策については、以下の記事やガイドも併せてご覧ください。

https://www.cloudflare.com/ja-jp/products/turnstile/

https://stripe.com/docs/radar/rules/reference#metadata-attributes

https://stripe.com/jp/radar/fraud-teams

また、「オンライン不正行為についてのレポート」を公開しています。

不正利用への対策や業種・国・地域ごとの傾向の違い、そしてStripeを利用した対策方法などについて紹介していますので、ぜひご覧ください。




## 関連記事

https://zenn.dev/kameoncloud/articles/cdf8f67bd8ce6f


https://stripe.com/docs/payments/quickstart

https://stripe.com/docs/payments/accept-a-payment-deferred