[実例コードあり PHP+Google reCAPTCHA V3]チャレンジをプログラマティックに呼び出す

PHP

お疲れ様です。すぺきよです。

前回に引き続き、Google reCAPTCHA V3のPHPにおいての実装方法です。

Googleのガイドに「チャレンジをプログラマティックに呼び出す」と題しての解説があります。

最初プログラマティックってなんやねん。と思い解説を読んでいると、どうやらJavaScriptのajaxやfetchを使って情報を投稿するタイプのWebアプリケーション向けのreCAPTCHA V3の実装方法のようです。

こちらの実装手順をPHPのコードの実装例と共に紹介します。

解説を全て読むのが面倒だという場合は、スタート地点のコードと、完成のコードの差分(diff)を見ていただくだけでもある程度使い方がわかると思います

では参りましょう。

今回の解説の範囲

今回の解説の範囲は、Googleのガイドの「チャレンジをプログラマティックに呼び出す」の実装部分の範囲に絞ります。

サイトキーやシークレットキーの取得手順はあちこちで解説されていますし、それほど難しくないと思うので省略します。

スタート地点

まずは実装するためのスタート地点です、

以下のコードを含むphpファイルとjsファイルをWebサーバーに配置してください。

簡単なお問い合わせ画面が表示され、送信ボタンをクリックすることでお問合せ内容が表示されるだけの画面になっています。

contactus.php

<!DOCTYPE html>
<html>
<head>
    <script src ="contactus.js" ></script>
</head>
<body>
    <form id="contact_form" method="post">
        <dl>
            <dt>お名前:</dt>
            <dd><input type="text" name="name" value="" placeholder="山田 太郎"></dd>
        </dl>
        <dl>
            <dt>お問い合わせ内容:</dt>
            <dd><textarea name="inquiry_body" cols="30" rows="10" placeholder="お問い合わせ内容を記入してください。"></textarea></dd>
        </dl>
        <button type="button" id="btn_submit">送信</button>
    </form>
</body>
</html>

こちらを実装すると以下のようなシンプルなお問合せ投稿画面になります。

contactus.js

投稿画面の動作を定義するjsファイルです。

ここでは、送信ボタンをクリックすると、fetchで投稿内容をPOSTし、成功したら受付完了画面に遷移してします。

window.addEventListener("load", () => 
{
    document.querySelector("#btn_submit").addEventListener("click", btnSubmit_onclick);
});

async function btnSubmit_onclick()
{
    const fd = new FormData(document.querySelector("#contact_form"));
    
    const post_url = "./acceptContactUs.php";
    
    const response = await fetch(post_url, {method: "POST", body:fd});
    if(!response.ok)
    {
        alert("通信に失敗しました。[" + response.status + ":" + response.statusText + "]");
        return;
    }
    
    const result = await response.json();
    
    if(result.success)
    {
        location.href = "accepted.php?id=" + encodeURIComponent(result.contact_id);
    }
    else
    {
        alert("お問い合わせの送信に失敗しました。");
        return;
    }
}

acceptContactUs.php

contactus.jsからfetchを使ってPOST送信されてきた情報を受け取る部分です。

投稿データを受付完了画面に渡すために、一度ローカルファイルに書き出しています。

DBがあるのであればそちらでもいいですし、環境によって保存先を調整してください。

この実装方法を見て、ファイルに一度書き出さずともcontactus.phpからaccepted.phpにURLパラメータやPOSTを使って直接情報を送信すればいいと思いませんでしたか?
もし、そう思ったのであれば、Webアプリケーション開発時には常に「信頼境界線」を意識するようにしましょう。

<?php
main();
exit(0);

function main()
{
    if(strcmp(strtoupper($_SERVER["REQUEST_METHOD"]),"POST") !== 0)
    {
        http_response_code(405);
        exit(0);
    }

    $contact_id = genUUID();

    $name = filter_input(INPUT_POST, "name");
    $inquiry_body = filter_input(INPUT_POST, "inquiry_body");
    $contact_contents = ["name" => $name, "inquiry_body" => $inquiry_body];
    
    file_put_contents(sys_get_temp_dir() . "/" . $contact_id, json_encode($contact_contents));

    $result = ["success" => true, "contact_id" => $contact_id];
    
    header("Content-Type: application/json; charset=utf-8");
    echo json_encode($result);
    exit(0);
}

function genUUID() : string
{
    $uuid4_pattern = "RRRRRRRR-RRRR-4RRR-rRRR-RRRRRRRRRRRR";
    $uuid = "";
    
    for($i = 0; $i < strlen($uuid4_pattern); $i++)
    {
        $pattern = substr($uuid4_pattern, $i, 1);
        $add_code = $pattern;
        switch($pattern)
        {
            case "R":
                $add_code = dechex(random_int(0, 15));
                break;
            case "r":
                $add_code = dechex(random_int(8, 11));
            default:
                break;
        }
        $uuid .= $add_code;
    }

    return $uuid;
}

ここでは、問合せIDとしてUUIDを利用しています。
ここで、問合せIDを連番や日時ベースのIDにしてしまうと、悪意ある第三者に番号を予測、他人の問い合わせ内容に参照できる可能性があり、意図しない情報漏洩が発生します。
さらにここのIDを正しく実装されたUUIDにすることで、詳しい悪意ある第三者ほど攻撃を諦めます。

accepted.php

最終的に受付を完了したことを通知し、受け付けた内容を表示します。

情報を表示するためのデータを保存済みのファイルから読み取ったらunlinkでファイルを削除しています。

<?php
$contact_id = filter_input(INPUT_GET, "id");

$content_file_path = sys_get_temp_dir() . "/" . $contact_id;

if(!file_exists($content_file_path))
{
    http_response_code(404);
    exit(0);
}
$contact_content_json = file_get_contents($content_file_path);

$contact_content = json_decode($contact_content_json);

unlink($content_file_path);
?>
<!DOCTYPE html>
<html>
<head>
</head>
<body>
    <p>お問い合わせを受け付けました</p>
    <hr>
    <dl>
        <dt>お名前:</dt>
        <dd><?= htmlspecialchars($contact_content->name, ENT_QUOTES) ?></dd>
    </dl>
    <dl>
        <dt>お問い合わせ内容:</dt>
        <dd><?= nl2br(htmlspecialchars($contact_content->inquiry_body, ENT_QUOTES)) ?></dd>
    </dl>
    <button type="button" onclick="location.href = 'contactus.php'">戻る</button>
</body>
</html>

ここまでを実装すると、問い合わせの投稿完了時に以下の画面が表示されるようになります。

ステップ1:サイトキーとシークレットキーを定義

contactus.phpとacceptContactUs.phpの両方の一番上に、以下のコードを追加します。

<?php
const RECAPTCHA_V3_SITE_KEY = "6LeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXrql";
const RECAPTCHA_V3_SECRET_KEY = "6LeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEiK";
?>

ステップ2:contactus.phpにreCAPTCHA V3を設定

まずは。contactus.phpを書き換えます。

coutactus.php 書き換えるポイント1

Google reCAPTCHA V3のスクリプトの読み込みを行います。

contactus.phpの<head></head>の間を編集し、以下のように書き換えます。

before:

<head>
    <script src ="contactus.js" ></script>
</head>

after:

<head>
    <script src ="contactus.js" ></script>
    <script src="https://www.google.com/recaptcha/api.js?render=<?= urlencode(RECAPTCHA_V3_SITE_KEY) ?>"></script>
</head>

coutactus.php 書き換えるポイント2

次にJavaScript側でもサイトキーを受け取れるように、HTML内に書き出しておきます。

JavaScriptで受け取りやすい方法であれば、どこでも構いません。

今回は、非表示のinputのvalueに値をセットすることにしました。

そんなに変化のない値なので、HTML内ではなくJavaScriptに定数として直接書き出しておくのもありですが、個人的にはあちこちに同じパラメータを定義するのは好きじゃないので、この方法を取ることが多いです。
余談ですが、本当はRECAPTCHA_V3_SITE_KEYなども1箇所で定義したいですが、説明簡略化のために、今回はこうしています。

before:

<body>
    <form id="contact_form" method="post">

after:

<body>
    <input type="hidden" name="site_key" value="<?= htmlspecialchars(RECAPTCHA_V3_SITE_KEY, ENT_QUOTES) ?>">
    <form id="contact_form" method="post">

ここまでを実装すると、画面の右下にGoogle reCAPTCHAのアイコンが表示されることを確認できると思います。

ステップ3:contactus.jsにreCAPTCHA用のコードを追加

fetchでデータをPOSTするためのスクリプトを含むcontactus.jsにコードを追加します。

contactus.js 書き換えるポイント1

まずは、Google reCAPTCHAの検証用トークンを受け取るためのコードを一番最後に追加します。

共通で使えるように関数化、かつ同期処理ができるようにPromise化しています。

function getRecaptchaTokenAsync(site_key)
{
    return new Promise((resolve, _reject) => 
    {
        grecaptcha.ready(function() 
        {
            grecaptcha.execute(site_key, {action: 'submit'}).then(function(token) 
            {
                resolve(token);
            });
        });
    })
}

contactus.js 書き換えるポイント2

次に実際にfetchでPOSTする際に同時に送信する検証用のトークンを取得し、送信する部分を実装します。

reCAPTCHA用のトークンをPromiseで実装しているので同期処理になって実装がシンプルになります。

ここでは、まず、FormDataオブジェクトを作り、コンストラクタで問い合わせ画面のフォームデータを取り込みます。

その後、そのFormDataにreCAPTCHAのトークンデータをappendしてfetchにて送信しています。

before:

async function btnSubmit_onclick()
{
    const fd = new FormData(document.querySelector("#contact_form"));

    const post_url = "./acceptContactUs.php";

after:

async function btnSubmit_onclick()
{
    const fd = new FormData(document.querySelector("#contact_form"));

    const site_key = document.querySelector("input[type=hidden][name=site_key]").value;
    const recaptcha_token = await getRecaptchaTokenAsync(site_key);
    fd.append("rt",recaptcha_token);
    // console.log(recaptcha_token); return;

    const post_url = "./acceptContactUs.php";

getRecaptchaTokenAsyncから受け取ったrecaptcha_tokenをJavaScriptの「 // console.log(recaptcha_token); return;」のコメントアウトをはずしてデバッグ出力すると、以下のような出力が確認できる用になります。

ステップ4:acceptContactUs.phpでトークンを検証

最後に、acceptContactUs.phpに送られてきたトークンを検証します。

acceptContactUs.php 書き換えポイント1

ユーザートークンの検証用の関数を定義します。

acceptContactUs.phpの一番最後に以下の関数をそのまま追加します。

/**
 * reCAPTCHAユーザートークンのチェックをおこなう
 *
 * @param string|null $token チェックボックスからPOSTされたトークン($_POST[ 'rt' ])
 * @param string $secret_key Google reCAPTCHA V3で取得したシークレットトークン
 * @return bool true:チェックOK false:チェックNG
 */
function checkReCaptchaUserToken(?string $token, string $secret_key) : bool
{
    if($token === null)
    {
        return false;
    }
    
    $data = ['secret' => $secret_key,'response' =>  $token];
    $context = 
    [
        'http' => 
        [
            'method'  => 'POST',
            'header'  => implode("\r\n", array('Content-Type: application/x-www-form-urlencoded')),
            'content' => http_build_query($data)
        ]
    ];

    $api_response = file_get_contents("https://www.google.com/recaptcha/api/siteverify", false, stream_context_create($context));
    $response = json_decode($api_response);

    return $response->success;
}

こちらは前回の実装コードとほぼ同じものです。(コメントだけ書き換えています。)

実装内容のインターフェースは単純で、与えられたトークンとシークレットキーを利用して検証、その後検証結果のみを返しています。

acceptContactUs.php 書き換えポイント2

次に、ユーザーから送信されてきたreCHAPTCHA用のトークンと、シークレットキーをcheckReCaptchaUserToken関数に渡して、検証を行う部分を追加します。

検証結果で検証に失敗した場合は401エラーを返します。

今回は、送信されてきた問い合わせ内容を受け取って保存する前に処理を追加しました。

before:


    if(strcmp(strtoupper($_SERVER["REQUEST_METHOD"]),"POST") !== 0)
    {
        http_response_code(405);
        exit(0);
    }

    $contact_id = genUUID();

after:


    if(strcmp(strtoupper($_SERVER["REQUEST_METHOD"]),"POST") !== 0)
    {
        http_response_code(405);
        exit(0);
    }

    $gr_user_token = filter_input(INPUT_POST, "rt");
    if(!checkReCaptchaUserToken($gr_user_token, RECAPTCHA_V3_SECRET_KEY))
    {
        http_response_code(401);
        exit(0);
    }
    
    $contact_id = genUUID();

実装完了、だけど・・・

ここまでで実装は完了ですが、今回は特に実際にうまく実装できているかは正直わかりにくいです。

正直「checkReCaptchaUserToken」関数の返り値を常にfalseにするくらいしかテストできないと思います。

もしくは「checkReCaptchaUserToken」関数の「$response」の中身をvar_dumpしてみたり、error_logに書き出したりしてみましょう。

そこで以下のように値が返ってきていることが確認できれば、実装は問題ないと思います。(今回はシンプルにvar_dumpで出力し、fetchのレスポンスを開発ツールにてプレビューしています。)

完成

最後に完成系のコードです。

contactus.php

<?php 
const RECAPTCHA_V3_SITE_KEY = "6LeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXrql";
const RECAPTCHA_V3_SECRET_KEY = "6LeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEiK";
?><!DOCTYPE html>
<html>
<head>
    <script src ="contactus.js" ></script>
    <script src="https://www.google.com/recaptcha/api.js?render=<?= urlencode(RECAPTCHA_V3_SITE_KEY) ?>"></script>
</head>
<body>
    <input type="hidden" name="site_key" value="<?= htmlspecialchars(RECAPTCHA_V3_SITE_KEY, ENT_QUOTES) ?>">
    <form id="contact_form" method="post">
        <dl>
            <dt>お名前:</dt>
            <dd><input type="text" name="name" value="" placeholder="山田 太郎"></dd>
        </dl>
        <dl>
            <dt>お問い合わせ内容:</dt>
            <dd><textarea name="inquiry_body" cols="30" rows="10" placeholder="お問い合わせ内容を記入してください。"></textarea></dd>
        </dl>
        <button type="button" id="btn_submit">送信</button>
    </form>
</body>
</html>

contactus.js

window.addEventListener("load", () => 
{
    document.querySelector("#btn_submit").addEventListener("click", btnSubmit_onclick);
});

async function btnSubmit_onclick()
{
    const fd = new FormData(document.querySelector("#contact_form"));

    const site_key = document.querySelector("input[type=hidden][name=site_key]").value;
    const recaptcha_token = await getRecaptchaTokenAsync(site_key);
    fd.append("rt",recaptcha_token);
    // console.log(recaptcha_token); return;

    const post_url = "./acceptContactUs.php";
    
    const response = await fetch(post_url, {method: "POST", body:fd});
    if(!response.ok)
    {
        alert("通信に失敗しました。[" + response.status + ":" + response.statusText + "]");
        return;
    }
    
    const result = await response.json();
    
    if(result.success)
    {
        location.href = "accepted.php?id=" + encodeURIComponent(result.contact_id);
    }
    else
    {
        alert("お問い合わせの送信に失敗しました。");
        return;
    }
}

function getRecaptchaTokenAsync(site_key)
{
    return new Promise((resolve, _reject) => 
    {
        grecaptcha.ready(function() 
        {
            grecaptcha.execute(site_key, {action: 'submit'}).then(function(token) 
            {
                resolve(token);
            });
        });
    })
}

acceptContactUs.php

<?php
const RECAPTCHA_V3_SITE_KEY = "6LeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXrql";
const RECAPTCHA_V3_SECRET_KEY = "6LeXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEiK";

main();
exit(0);

function main()
{
    if(strcmp(strtoupper($_SERVER["REQUEST_METHOD"]),"POST") !== 0)
    {
        http_response_code(405);
        exit(0);
    }

    $gr_user_token = filter_input(INPUT_POST, "rt");
    if(!checkReCaptchaUserToken($gr_user_token, RECAPTCHA_V3_SECRET_KEY))
    {
        http_response_code(401);
        exit(0);
    }
    
    $contact_id = genUUID();

    $name = filter_input(INPUT_POST, "name");
    $inquiry_body = filter_input(INPUT_POST, "inquiry_body");
    $contact_contents = ["name" => $name, "inquiry_body" => $inquiry_body];
    
    file_put_contents(sys_get_temp_dir() . "/" . $contact_id, json_encode($contact_contents));

    $result = ["success" => true, "contact_id" => $contact_id];
    
    header("Content-Type: application/json; charset=utf-8");
    echo json_encode($result);
    exit(0);
}

function genUUID() : string
{
    $uuid4_pattern = "RRRRRRRR-RRRR-4RRR-rRRR-RRRRRRRRRRRR";
    $uuid = "";
    
    for($i = 0; $i < strlen($uuid4_pattern); $i++)
    {
        $pattern = substr($uuid4_pattern, $i, 1);
        $add_code = $pattern;
        switch($pattern)
        {
            case "R":
                $add_code = dechex(random_int(0, 15));
                break;
            case "r":
                $add_code = dechex(random_int(8, 11));
            default:
                break;
        }
        $uuid .= $add_code;
    }

    return $uuid;
}

/**
 * reCAPTCHAユーザートークンのチェックをおこなう
 *
 * @param string|null $token チェックボックスからPOSTされたトークン($_POST[ 'g-recaptcha-response' ])
 * @param string $secret_key Google reCAPTCHA V3で取得したシークレットトークン
 * @return bool true:チェックOK false:チェックNG
 */
function checkReCaptchaUserToken(?string $token, string $secret_key) : bool
{
    if($token === null)
    {
        return false;
    }
    
    $data = ['secret' => $secret_key,'response' =>  $token];
    $context = 
    [
        'http' => 
        [
            'method'  => 'POST',
            'header'  => implode("\r\n", array('Content-Type: application/x-www-form-urlencoded')),
            'content' => http_build_query($data)
        ]
    ];

    $api_response = file_get_contents("https://www.google.com/recaptcha/api/siteverify", false, stream_context_create($context));
    $response = json_decode($api_response);
var_dump($response);
    return $response->success;
}

accepted.php

<?php
$contact_id = filter_input(INPUT_GET, "id");

$content_file_path = sys_get_temp_dir() . "/" . $contact_id;

if(!file_exists($content_file_path))
{
    http_response_code(404);
    exit(0);
}
$contact_content_json = file_get_contents($content_file_path);

$contact_content = json_decode($contact_content_json);

unlink($content_file_path);
?>
<!DOCTYPE html>
<html>
<head>
</head>
<body>
    <p>お問い合わせを受け付けました</p>
    <hr>
    <dl>
        <dt>お名前:</dt>
        <dd><?= htmlspecialchars($contact_content->name, ENT_QUOTES) ?></dd>
    </dl>
    <dl>
        <dt>お問い合わせ内容:</dt>
        <dd><?= nl2br(htmlspecialchars($contact_content->inquiry_body, ENT_QUOTES)) ?></dd>
    </dl>
    <button type="button" onclick="location.href = 'contactus.php'">戻る</button>
</body>
</html>

さいごに

以上で、「チャレンジをプログラマティックに呼び出す」の解説は終わりです。

正直いうと、「チャレンジをボタンに自動的にバインドする」で実装した場合より複雑です。

その分、JavaScriptでプログラマティックに送信先を書き換えたり、送信内容の加工をしたりという処理との相性が良いです。

タイトルとURLをコピーしました