3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

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


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

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

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

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

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

ありました!👀

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

👷 使ってみた

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

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

docker compose build
docker compose run --rm app composer install

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

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

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

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

スクリーンショット 2024-12-14 19.28.15.png


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

cp .env.example .env
.env
PAYJP_PUBLIC_KEY=pk_xxxxxxxxxx
PAYJP_SECRET_KEY=sk_xxxxxxxxxx
docker compose up -d

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

スクリーンショット 2024-12-14 21.35.08.png

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

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

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

subwindow-sample.gif

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

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

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

redirect-sample.gif

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

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

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

Checkout について

全体のコード

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 を埋め込んでいいようです。

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>

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

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 が設定されています。

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

response には token オブジェクト が入るようですね。

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 のどちらかを設定しておくことになるでしょう。

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

全体コードはこちら。

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 を実行すれば良さそうでした。

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://pay.jp/docs/guideline-payjp-three-d-secure#redirect

リダイレクトの制御部分

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 で送られてくるので、この情報をセッションに保持してますね。

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

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

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

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;

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

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

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>';

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

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

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

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

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();

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

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

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

✍ 最後に

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

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

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

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

.

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?