PAY.JPの3Dセキュア認証の実装サンプルを使ってみた

こんにちはのんです。
Qiita に投稿するのはめちゃくちゃ久しぶりです。

今回は [オンライン決済サービスPAY.JPを使ってみた情報をシェアしよう！ by PAY Advent Calendar 2024](https://qiita.com/advent-calendar/2024/payjp-01) を見つけて、とあるサービス実装に PAY.JP を利用する機会があり、そこで困った3Dセキュア認証について投稿しようと思います。

---

早速ですが、ついこの間 PAY.JP の運営からこんな通知が……

https://pay.jp/info/2024/07/16/110000

結構大きな変更のようで、何回か再案内や仕様の説明があったようです。

どうやら、2025年末までに3Dセキュアとやらを実装しなければならないらしい……

私は非常に困りました。なぜなら、**3Dセキュアなんもわからん😇**

## 🧭 サンプルを探す旅に出ること数日……ついに3Dセキュア実装サンプルを見つける

https://github.com/payjp/payjp-php-tds-sample

**ありました！👀**

どうやら簡単に決済フォームが実装できる Checkout と PHP が使われているようですね。
私の第一母国語が PHP なので、これも非常に助かりポイントです。

## 👷 使ってみた

README.md を読む限り Docker を利用していそうです。
ファイル構造的に簡単なので、一応 PHP が動くサーバーがあれば問題なさそうですが、素直に Docker を利用することにしましょう。

Docker をインストールしたあと、下記のコマンドを実行します。

```sh
docker compose build
```

```sh
docker compose run --rm app composer install
```

それぞれ、コンテナのビルドと Composer パッケージのインストールをしているようです。
composer は app コンテナの中に含まれていそうなので使いたいときはこれを使うと良さそう。

次に下記のコマンドを実行します。

```sh
PAYJP_PUBLIC_KEY=お手持ちの公開鍵 PAYJP_SECRET_KEY=お手持ちの秘密鍵 docker compose up -d
```

`PAYJP_PUBLIC_KEY`, `PAYJP_SECRET_KEY` にはそれぞれ下図の API キーを設定します。

![スクリーンショット 2024-12-14 19.28.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192516/28ae0190-013c-3065-4593-2d462772a63e.png)

---

※ `.env.example` ファイルもあるようですし、これを `.env` に作成、コピーして記載しても良さそうですね。

```sh
cp .env.example .env
```

```:.env
PAYJP_PUBLIC_KEY=pk_xxxxxxxxxx
PAYJP_SECRET_KEY=sk_xxxxxxxxxx
```

```sh
docker compose up -d
```

---

ここまで実行できれば http://localhost へアクセスができるようになっているはずです。

![スクリーンショット 2024-12-14 21.35.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192516/98c10cba-8cd6-eb12-7616-5d460519ffbf.png)

画面を読むと他のサンプルも追加されそうな雰囲気がします……👀

## ⚙️ サブウィンドウ型を動かしてみよう

テストモードで利用できるテストカードの一覧は下記に記載されています。
https://pay.jp/docs/testcard

![subwindow-sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192516/0cc28393-1f4d-8b1a-6de9-6bd3e993f688.gif)

どうやらちゃんと動いているようです。
サブウィンドウ型は小さなウィンドウが出て、そこにカード会社の認証画面が表示されるようですね。

## ⚙️ リダイレクト型を動かしてみよう

テストモードで利用できるテストカードの一覧は下記に記載されています。
https://pay.jp/docs/testcard

![redirect-sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192516/5a7a6956-0ac6-7808-f6c2-d9b10aa3498a.gif)

どうやらちゃんと動いているようです。
リダイレクト型は今いる画面がカード会社の認証画面へリダイレクトされて、その後元の場所に戻って来るようですね。

## ✍ ちょっとしたコードの解説

公式のサンプルを利用しているので、この記事の情報の鮮度については今後も注意してください。というの前提に、ちょっとしたコードの解説をしちゃおうかなと思います。

### Checkout について

全体のコード

```php:index.php
<?php
declare(strict_types=1);

require_once __DIR__ . '/../libs/csrfToken.php';

session_start();
$csrfToken = generateCsrfToken();
?>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8"/>
    <title>PAY.JP サブウィンドウ型3Dセキュア実装サンプル</title>
</head>
<body>

<script type="text/javascript">
    function onCreatedToken(response) {
        console.log(response);
        document.querySelector('#created-token').textContent = response.id;
    }
</script>

<h1>支払いフォーム例</h1>

<form action="/sub-window/create-charge.php" method="post">
    <p>おにぎり 100円</p>

    <div style="display: flex; gap: 1rem; align-items: center;">
        <div>
            <script
                type="text/javascript"
                src="https://checkout.pay.jp/prerelease"
                class="payjp-button"
                data-payjp-key="<?php echo htmlspecialchars($_ENV['PAYJP_PUBLIC_KEY'] ?? ''); // `pk_` から始まる公開鍵を設定してください。 ?>"
                data-payjp-three-d-secure="true"
                data-payjp-three-d-secure-workflow="subwindow"
                data-payjp-extra-attribute-email
                data-payjp-extra-attribute-phone
                data-payjp-partial="true"
                data-payjp-on-created="onCreatedToken"
            ></script>
        </div>
        <div>
            <small>※ <?php echo htmlspecialchars('<input type="hidden" name="payjp-token">'); ?> の value に token が自動的にセットされます。</small><br />
            <small>※ 生成されたトークン: <span id="created-token"></span></small>
        </div>
    </div>
    <br />
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>" />
    <button type="submit">支払う</button>
</form>
<br />
<a href="/">戻る</a>
</body>
</html>
```

主にここ、 `<form />` の中にカード情報入力フォームである Checkout を埋め込んでいいようです。

```html:index.php
<form action="/sub-window/create-charge.php" method="post">
    <p>おにぎり 100円</p>

    <div style="display: flex; gap: 1rem; align-items: center;">
        <div>
            <script
                type="text/javascript"
                src="https://checkout.pay.jp/prerelease"
                class="payjp-button"
                data-payjp-key="<?php echo htmlspecialchars($_ENV['PAYJP_PUBLIC_KEY'] ?? ''); // `pk_` から始まる公開鍵を設定してください。 ?>"
                data-payjp-three-d-secure="true"
                data-payjp-three-d-secure-workflow="subwindow"
                data-payjp-extra-attribute-email
                data-payjp-extra-attribute-phone
                data-payjp-partial="true"
                data-payjp-on-created="onCreatedToken"
            ></script>
        </div>
        <div>
            <small>※ <?php echo htmlspecialchars('<input type="hidden" name="payjp-token">'); ?> の value に token が自動的にセットされます。</small><br />
            <small>※ 生成されたトークン: <span id="created-token"></span></small>
        </div>
    </div>
    <br />
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>" />
    <button type="submit">支払う</button>
</form>
```

#### 作成されたトークンはどこに……？

```html:index.php
<input type="hidden" name="payjp-token">
```

トークンの作成が成功したタイミングで上記のように Element が生成されているようでした。
`form.submit` のタイミングで `payjp-token` として送信できそうですね。

#### `data-payjp-three-d-secure="true"`

3Dセキュア認証をする場合は `"true"` を設定するようです。
このとき、`data-payjp-three-d-secure-workflow` に設定する値によってサブウィンドウ型かリダイレクト型かを指定できるようですね。

| 種類 | 値 | 
| --- | --- | 
| サブウィンドウ型 | `data-payjp-three-d-secure-workflow="subwindow"` |
| リダイレクト型 | `data-payjp-three-d-secure-workflow="redirect"` |

#### `data-payjp-on-created`

`data-payjp-on-created` には下のコードで定義された `onCreatedToken` が設定されています。

```html:index.php
<script type="text/javascript">
    function onCreatedToken(response) {
        console.log(response);
        document.querySelector('#created-token').textContent = response.id;
    }
</script>
```

`response` には [token オブジェクト](https://pay.jp/docs/api/#token%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88) が入るようですね。

```html:index.php
<small>※ 生成されたトークン: <span id="created-token"></span></small>
```

に生成されたトークンを表示するための処理が書かれているようです。

#### `data-payjp-partial="true"`

自分で `form.submit` をコントロールするためにこのサンプルでは `data-payjp-partial="true"` が設定されています。

このサンプルは必要最低限しか実装されておらず、サービスで実際に利用するときは他にも色々入力する必要があるフォームがたくさんあるでしょう。
これらを自分でコントロールするためのプロパティのようです。

#### `data-payjp-extra-attribute-email` / `data-payjp-extra-attribute-phone`

公式ドキュメントを読む限り、3Dセキュアをするときは `カード名義 and ( メールアドレス or 電話番号 )` が必須になるようです。
Checkout にはカード名義がデフォルトで設定されているようなので `data-payjp-extra-attribute-email` or `data-payjp-extra-attribute-phone` のどちらかを設定しておくことになるでしょう。

https://pay.jp/docs/guideline-payjp-three-d-secure#extra-attribute

### サブウィンドウ型のバックエンドについて

全体コードはこちら。

```php:create-charge.php
<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../libs/csrfToken.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

// csrf token の検証などを行ってください。このサンプルでは本題と関係ないため省略します。
session_start();
verifyCsrfToken();

// トークンを取得します。
$payjpToken = $_POST['payjp-token'] ?? '';

// トークンを使って支払いを行います。
Payjp\Payjp::$apiKey = $_ENV['PAYJP_SECRET_KEY'] ?? ''; // `sk_` から始まる秘密鍵を設定してください。
$charge = Payjp\Charge::create([
    'card' => $payjpToken,
    'amount' => 100,
    'currency' => 'jpy',
]);

// 支払い後に必要な処理を行ってください。

echo '支払いが完了しました。<br />';
echo $charge->id . '<br />';
echo $charge->amount . '<br />';

echo '<a href="/">戻る</a>';
```

実際に重要な処理はこの辺ですね。
サブウィンドウ型で作成したトークンはそのまま支払いに使えるので、特段やるべき処理はなさそうで、素直に支払い API を実行すれば良さそうでした。

```php:create-charge.php
// トークンを取得します。
$payjpToken = $_POST['payjp-token'] ?? '';

// トークンを使って支払いを行います。
Payjp\Payjp::$apiKey = $_ENV['PAYJP_SECRET_KEY'] ?? ''; // `sk_` から始まる秘密鍵を設定してください。
$charge = Payjp\Charge::create([
    'card' => $payjpToken,
    'amount' => 100,
    'currency' => 'jpy',
]);
```

### リダイレクト型のバックエンドについて

この手法がちょっとわけわからんくなりそう。
リダイレクトがいくつか挟まるので全体のフローを知っておく必要がありそうです。

これも公式から引用してきた図ですが、このような感じ。

![スクリーンショット 2024-12-14 20.45.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192516/816d0e84-99af-e8c6-8b28-c69dc9433dd3.png)

> https://pay.jp/docs/guideline-payjp-three-d-secure#redirect

#### リダイレクトの制御部分

```php:redirect-to-tds-page.php
<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../libs/csrfToken.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

// csrf token の検証などを行ってください。このサンプルでは本題と関係ないため省略します。
session_start();
verifyCsrfToken();

// トークンを取得します。
$payjpToken = $_POST['payjp-token'] ?? '';

// 入力内容をセッションに保存しておきます。
$_SESSION['tds_input_data'] = [
    'payjp_token' => $payjpToken,
];

// 戻り先 URL ( `back_url` ) を設定します。ここでは firebase/php-jwt を使用しています。
// https://pay.jp/docs/api/#3d%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E9%96%8B%E5%A7%8B
$jws = Firebase\JWT\JWT::encode(
    [
        'url' => 'http://localhost/redirect/callback.php',
    ],
    $_ENV['PAYJP_SECRET_KEY'] ?? '',
    'HS256'
);

header("Location: https://api.pay.jp/v1/tds/$payjpToken/start?publickey={$_ENV['PAYJP_PUBLIC_KEY']}&back_url=$jws");
exit;
```

Checkout で生成したトークンは `payjp-token` で送られてくるので、この情報をセッションに保持してますね。

```php:redirect-to-tds-page.php
// トークンを取得します。
$payjpToken = $_POST['payjp-token'] ?? '';

// 入力内容をセッションに保存しておきます。
$_SESSION['tds_input_data'] = [
    'payjp_token' => $payjpToken,
];
```

そのあと、コールバック先 URL が乗った情報を JWS 方式に固め直して然るべきエンドポイントへリダイレクトしているようです。

```php:redirect-to-tds-page.php
// 戻り先 URL ( `back_url` ) を設定します。ここでは firebase/php-jwt を使用しています。
// https://pay.jp/docs/api/#3d%E3%82%BB%E3%82%AD%E3%83%A5%E3%82%A2%E9%96%8B%E5%A7%8B
$jws = Firebase\JWT\JWT::encode(
    [
        'url' => 'http://localhost/redirect/callback.php',
    ],
    $_ENV['PAYJP_SECRET_KEY'] ?? '',
    'HS256'
);

header("Location: https://api.pay.jp/v1/tds/$payjpToken/start?publickey={$_ENV['PAYJP_PUBLIC_KEY']}&back_url=$jws");
exit;
```

:::note alert
つまり、この時点で取得できるトークンでは**支払いを実行することができない**。ということになりそうです。
:::

#### コールバック先で支払いを実行する

```php:callback.php
<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../libs/csrfToken.php';

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    http_response_code(405);
    exit;
}

session_start();

// セッションから保持していたトークンを再取得します。
$payjpToken = $_SESSION['tds_input_data']['payjp_token'] ?? '';
unset($_SESSION['tds_input_data']);

// 3D セキュア認証が完了した後に、3Dセキュアフローを完了させます。
Payjp\Payjp::$apiKey = $_ENV['PAYJP_SECRET_KEY'] ?? ''; // `sk_` から始まる秘密鍵を設定してください。
$token = Payjp\Token::retrieve($payjpToken);

// この時点での `$token->card->three_d_secure_status` の状態を見たりして処理を判断することもできます。

// 3Dセキュアフロー完了します。忘れがちなので注意してください。
$token->tdsFinish();

// 3Dセキュアフローが完了したトークンを用いて、支払いを行います。
$charge = Payjp\Charge::create([
    'card' => $token->id,
    'amount' => 100,
    'currency' => 'jpy',
]);

// 支払い後に必要な処理を行ってください。

echo '支払いが完了しました。<br />';
echo $charge->id . '<br />';
echo $charge->amount . '<br />';

echo '<a href="/">戻る</a>';
```

セッションから保持していたトークンを取得します。

```php:callback.php
// セッションから保持していたトークンを再取得します。
$payjpToken = $_SESSION['tds_input_data']['payjp_token'] ?? '';
unset($_SESSION['tds_input_data']);
```

次に PAY.JP の 3Dセキュアフローを完了させるようです。

:::note alert
つまり、 **PAY.JP の 3Dセキュアフローを完了するまで**、このトークンでは**支払いを実行することができない**。ということになりそうです。
:::

```php:callback.php
// 3D セキュア認証が完了した後に、3Dセキュアフローを完了させます。
Payjp\Payjp::$apiKey = $_ENV['PAYJP_SECRET_KEY'] ?? ''; // `sk_` から始まる秘密鍵を設定してください。
$token = Payjp\Token::retrieve($payjpToken);

// この時点での `$token->card->three_d_secure_status` の状態を見たりして処理を判断することもできます。

// 3Dセキュアフロー完了します。忘れがちなので注意してください。
$token->tdsFinish();
```

公式のコメントにも書いてありますが、確かにこれは忘れそう……😭

ここまで来て晴れて支払いを実行できます。

```php:callback.php
// 3Dセキュアフローが完了したトークンを用いて、支払いを行います。
$charge = Payjp\Charge::create([
    'card' => $token->id,
    'amount' => 100,
    'currency' => 'jpy',
]);
```

## ✍ 最後に

わけわからんかった3Dセキュアもこのサンプルで勉強することができました😆
改めてリポジトリへのリンクを貼っておきましょう。

https://github.com/payjp/payjp-php-tds-sample

commit を見ると、どうやら本当に最近公開されたようです。[^1]

[^1]: とまぁ、ここまで読めば気づく人は気づきますね。サンプルは主に私が書きました。今までサンプル用意してなくて本当にごめんなさい 🙏

3Dセキュア処理難しくてわかりませんでしたが、手元で動作する環境ごとくれるのはうれしいですね！👍

きっと公式情報の更新もあるでしょうし、記事の更新やら追記やらするかもしれません。
そのときはよしなに。

.