MW WP FormにプラグインなしでreCAPTCHA v3を設定するメモ

# プラグインなしで自作する背景

MW WP Formでスパムメール対応をする場合、以前はMW WP Formの本家のアドオン「MW WP Form reCAPTCHA」がありましたが、今は配布停止しているようです。
現在は **「reCAPTCHA for MW WP Form」** というプラグインでGoogle reCAPTCHA v3を簡単に導入できます。
ただしこのプラグインのreCAPTCHAトークンの取得方法がページが表示されると同時に取得するという仕様のため若干の問題になるケースがあるようです。
Google reCAPTCHA v3のトークンの有効時間制限が2分くらいとなっていて、入力などに時間がかかるとトークンの有効期限が切れてエラーになってしまいます。

https://wordpress.org/support/topic/submit-fails-with-timeout-reopen-issue/

そこで、Google reCAPTCHA v3の自体の設定自体は簡単なので自作すればできるのではと思いやってみました。

https://developers.google.com/recaptcha/docs/v3

# フォーム設定時の条件

MW WP Formは確認画面のあるフォームです。
その設定は各ページを同じURLにしたり、別々のURLにしたり、または確認画面を表示させないなど様々に設定できます。

ここでは、各ページとも違うページ（URL）として設定した場合で進めていきます。
また、アドオンプラグインとして設定もできるかと思いますが、とりあえずテーマの設定として簡潔に記載していきます。

# reCAPtCHAの各キーの取得

こちらはGoogleなどで検索すると説明しているブログなどがあるのでそちらを参考に、```サイトキー```と```シークレットキー```を取得しておいてください。

[Google検索 - recaptcha v3 取得](https://www.google.com/search?q=recaptcha+v3+%E5%8F%96%E5%BE%97&rlz=1C5CHFA_enJP830JP830&oq=reCap&aqs=chrome.0.69i59l2j69i57j69i59j0i433i512j69i60l3.2621j0j4&sourceid=chrome&ie=UTF-8)

# 自作バリテーションルールを追加する

MW WP Formはとてもカスタマイズがしやすいようにいろいろなフックやその他の機能があります。
```mwform_validation_rules```フィルターフックにより自作バリテーションを追加できます。

https://plugins.2inc.org/mw-wp-form/filter-hook/mwform_validation_rules/


上記の公式マニュアルではざっとの説明しかないないので、とりあえず下記にコメント説明付きの追加したソースを記述します。


```php:functions.php
/*
 * MW WP Formの自作バリテーション
 */
function mwform_validation_recaptcha( $validation_rules ) {

    if(! class_exists('MW_Validation_Recaptcha')) {

        
        class MW_Validation_Recaptcha extends MW_WP_Form_Abstract_Validation_Rule {
            /*
             * 独自のバリデーションルール名を設定します。
             * 他のバリテーションを被らない名前が良いです。
             */
            protected $name = 'mwformrecaptcha';

             public function rule( $item_name, array $options = array() ) {

               //データが送信されていない（最初にフォームページが表示された）時は以下の処理をしない設定
                if( strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST' ) return '';
                
                /*
                 * 取得したトークンを取得
                 */
                $token = $this->Data->get($item_name);
                $token = !empty($token) ? $token : '';
                
                /*
                 * 管理画面のreCAPTCHAのバリテーションが設定されているかのチェックして変数にいれる
                 */
                $is_reCAPTCHA = isset($options['is_reCAPTCHA']) ? $options['is_reCAPTCHA'] : false;
                
                $secret_key = 'XXXXXXXXXX';//ここには取得したシークレットキーを記載します
                $threshold_score = 0.5;//閾値の設定
                
                //管理画面にてreCAPTCHAバリテーションのチェックがあった時の処理
                if($is_reCAPTCHA !== false) {
                    
                    if($item_name === 'recaptchaToken' && !isset($_POST['submitBack'])) {
                        
                        if(!isset($secret_key) || $secret_key === '') {
                            $defaults = array(
                                'message' => __('No reCAPTCHA Secret Key','efc-theme')
                            );
                            $options = array_merge($defaults,$options);
                            return $options['message'];
                        }
                        //Google reCAPTCHA APIに投げて判定してもらう設定
                        $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $token;
                        $response = wp_remote_get($url);
                            if (!is_wp_error($response) && $response["response"]["code"] === 200) {
                                
                                $reCAPTCHA = json_decode($response["body"]);

                                if($reCAPTCHA->success) {
                                    if( $reCAPTCHA->score < $threshold_score) {
                                        
                                        $defaults = array(
                                            'message' => 'reCAPTCHAがスパムロボットと判断しましたので送信できませんでした!!'
                                        );
                                        $options = array_merge($defaults,$options);
                                        return $options['message'];
                                    }
                                } else {

                                    $defaults = array(
                                        'message' =>'reCAPTCHAがスパムロボットと判断しましたので送信できませんでした'
                                    );
                                    $options = array_merge($defaults, $options);
                                    return $options['message'];                                    
                                    
                                }
                                // \$reCAPTCHA->success
                                
                            } else {

                                $defaults = array(
                                    'message' => __('Faild reCAPtCHA Access','efc-theme')
                                );
                                $options = array_merge($defaults,$options);
                                return $options['message'];

                            }

                    }
                }
             }

            /*
             * フォーム編集画面の「バリデーションルール」に設定を追加する
             */
            public function admin($key, $value) {
                $is_reCAPTCHA = false;
                if (is_array($value[$this->getName()]) && isset($value[$this->getName()]['is_reCAPTCHA'])) {
                    $is_reCAPTCHA = $value[$this->getName()]['is_reCAPTCHA'];
                }
        ?>      
                <table>
                    <tr>
                        <td>reCAPTCHA V3</td>
                        <td><input type="checkbox" value="1" name="<?php echo MWF_Config::NAME; ?>[validation][<?php echo $key; ?>][<?php echo esc_attr($this->getName()); ?>][is_reCAPTCHA]" <?php if ($is_reCAPTCHA) : ?>checked<?php endif; ?> /></td>
                    </tr>
                </table>
        <?php
            }
        }
    }

    //上記ルールのインスタンスを作って返す
    if(!isset($instance)) {
        $instance = new MW_Validation_Recaptcha();
        $validation_rules[$instance->getName()] = $instance;
        return $validation_rules;
    }
}

add_filter('mwform_validation_rules','mwform_validation_recaptcha',20,1);

```

これをfunctions.phpに記述すると管理画面のバリテーションに以下のようなチェックが表示されます。

![スクリーンショット 2022-10-14 20.25.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34746/566ef01e-6020-4aff-8575-ddb382b9bea2.png)
```$secret_key = 'XXXXXXXXXX'```の箇所にはreCAPTCHAの取得したシークレットキーを入れます。
```$threshold_score = 0.5;```は設定したいreCAPTCHAのスコア閾値を入れます。大まかに0.5を境にbotと判断するケースが多いです。
この閾値を上げるとより厳しく判定する形になります。

# フォームの設定

reCAPTCHAのトークンを格納するための隠しフォームエレメントを設定します。
またエラーメッセージの位置を自由にできるように```mwform_error```の設定もしておきます。

```html
<!-- reCAPTCHAのトークンを格納するためのinput type="hidden"にて設定 -->
[mwform_hidden name="recaptchaToken"]

　<!-- エラ〜メッセージの表示したい位置にこちらを設置 -->
[mwform_error keys="recaptchaToken"]
```

そして```[mwform_hidden name="recaptchaToken"]```にバリテーションルールを追加して先ほどの```「reCAPTCHA V3」```のチェックを入れます。

# JavaScriptの設定

Google reCAPTCHA v3のドキュメント通りに設定していきます。
まずはJavaScript APIをsitekeyで読み込む設定を下記のように記述します。

```php:functions.php
$siteKey = 'XXXXXXX';//サイトキーを記述する
$loadReCaptcha = 'https://www.google.com/recaptcha/api.js?render=' . $siteKey;
//スクリプトを登録する
wp_enqueue_script('reCAPTCHv3',$loadReCaptcha,array(),'v3',true);
```
これを設定すると例のマークがサイト下方に例のマークが表示されます。
マークを特定のページのみ表示させたい場合は、

```php

global $post;
$slug = $post->post_name;

$consent_check_arg = array(
    "contact",//お問い合わせページのslug名
    "confirm"//確認画面のslug名
);

if(array_search($post->post_name,$consent_check_arg) !== false) {
    //ここに処理を記述する
}

```

みたいな感じで表示するページを絞ることもできます。

## 確認画面へのページ遷移での問題発生

次にトークンの取得するスクリプトを作成していきます。
トークンの取得のタイミングはGoogle公式マニュアルにあるように、送信時に取得する形にします。
通常のメールフォームであればこちらのスクリプトでいけます。

```js
const myForm = document.querySelector(".mw_wp_form_input form");
let preventEvent = true;//2重送信防止のフラグ
const getToken =  (e) => {
    const target = e.target;
    if(preventEvent) {
        e.preventDefault();
        grecaptcha.ready(function() {
            grecaptcha.execute("'. $siteKey .'", {action: "homepage"})
            .then(function(token) {//きちんとトークンが取得できた場合の処理
                preventEvent = false;//2重送信防止のフラグをfalseに
                //↓mwform_hidden で設定した値
                if(document.querySelector("[name=recaptchaToken]")) {
                    const recaptchaToken = document.querySelector("[name=recaptchaToken]");
                    recaptchaToken.value = token;
                }
                //送信する
                myForm.submit();
            })
            .catch(function(e) {
                alert("reCAPTCHA token取得時にエラーが発生したためフォームデータを送信できません");
                return false;
            });
        });

    }

}
myForm.addEventListener("submit",getToken);
```

しかし上記のようにした場合、MW WP Formの場合確認画面に行かずに完了画面（thanksページ）にすぐに行ってしまいます。
原因を調べたところMW WP Formのclass.data.phpの208行目の関数でsubmitボタンのnameの値によってページ遷移先を決めているようでした。

```php:class.data.php
	/**
	 * 送信データからどのページを表示すべきかの状態を判定して返す.
	 * Return post condition based on posted data.
	 * But this post condition is not the page to actually display (e.g. validation error).
	 *
	 * @return string back|confirm|complete|input
	 */
	public function get_post_condition() {
		$backButton    = $this->get_post_value_by_key( MWF_Config::BACK_BUTTON );
		$confirmButton = $this->get_post_value_by_key( MWF_Config::CONFIRM_BUTTON );

		if ( $backButton ) {
			return 'back';
		} elseif ( $confirmButton ) {
			return 'confirm';
		} elseif ( ! $confirmButton && ! $backButton && $this->_is_valid_token() ) {
			return 'complete';
		}

		return 'input';
	}
```

人がクリックした場合はsubmitボタンの値も送信されるのですが、JavaScriptの```submit()```では送信されません。
確認画面へ遷移させるには、```name="submitConfirm"```の値を送信する必要があります。
そこで処理に```name="submitConfirm"```の値を```input type="hidden"```にいれて、```submit()```で送信する形に修正しました。


```Js
const myForm = document.querySelector(".mw_wp_form_input form");
    let preventEvent = true;
    const getToken =  (e) => {
        const target = e.target;
        if(preventEvent) {
            e.preventDefault();
            grecaptcha.ready(function() {
                grecaptcha.execute("サイトキーをここに記載する", {action: "homepage"})
                .then(function(token) {
                    preventEvent = false;
                    if(document.querySelector("[name=recaptchaToken]")) {
                        const recaptchaToken = document.querySelector("[name=recaptchaToken]");
                        recaptchaToken.value = token;
                    }
                    //追加↓
                    if(myForm.querySelector("[name=submitConfirm]")) {
                        const confirmButtonValue = myForm.querySelector("[name=submitConfirm]").value;
                        const myComfirmButton = document.createElement("input");
                        myComfirmButton.type = "hidden";
                        myComfirmButton.value = confirmButtonValue;
                        myComfirmButton.name = "submitConfirm";
                        myForm.appendChild(myComfirmButton);
                    }
    
                    myForm.submit();
                })
                .catch(function(e) {
                    alert("reCAPTCHA token取得時にエラーが発生したためフォームデータを送信できません");
                    return false;
                });
            });
    
        }
    
    }
    myForm.addEventListener("submit",getToken);
```

こうすると問題なくトークンの取得ができ確認画面へ遷移することができました。

## 確認画面から完了画面への遷移での問題発生

確認画面ではURLが変わる設定としていましたので、ページごとに別途トークンを取得する必要があります。
上記のJavaScriptで同じようにトークンを取得できるはずです。

しかしそのまま送信ボタンをクリックするとトークンのエラーが返ってきてしまいます。
トークンの取得のタイミングの問題かと思い時間差をつけてみたりなどの工夫をしてみたのですが、どうしてもエラーになってしまいます。

この辺り原因がいまだに掴めないのですが、対応策として確認画面ではロード時すぐにトークンを取得する、そしてトークンの期限が切れる時間が来たらまた取得するという形にしてみましたら上手くいきました。

```js

if(document.querySelector(".mw_wp_form_input form")) {//最初のフォームエレメントがある場合
    const myForm = document.querySelector(".mw_wp_form_input form");
    let preventEvent = true;
    const getToken =  (e) => {
        const target = e.target;
        if(preventEvent) {
            e.preventDefault();
            grecaptcha.ready(function() {
                grecaptcha.execute("サイトキーをここに記載する", {action: "homepage"})
                .then(function(token) {
                    preventEvent = false;
                    if(document.querySelector("[name=recaptchaToken]")) {
                        const recaptchaToken = document.querySelector("[name=recaptchaToken]");
                        recaptchaToken.value = token;
                    }
                    if(myForm.querySelector("[name=submitConfirm]")) {
                        const confirmButtonValue = myForm.querySelector("[name=submitConfirm]").value;
                        const myComfirmButton = document.createElement("input");
                        myComfirmButton.type = "hidden";
                        myComfirmButton.value = confirmButtonValue;
                        myComfirmButton.name = "submitConfirm";
                        myForm.appendChild(myComfirmButton);
                    }

                    myForm.submit();
                })
                .catch(function(e) {
                    alert("reCAPTCHA token取得時にエラーが発生したためフォームデータを送信できません");
                    return false;
                });
            });

        }

    }
    myForm.addEventListener("submit",getToken);

} else if(document.querySelector(".mw_wp_form_confirm form")){//確認画面のエレメントがある場合

    let count=0;
    const timer = 60 * 1000 * 2;
    getToken = () => {
        grecaptcha.ready(function(){
            grecaptcha.execute("'. $siteKey .'",{action:"homepage"})
            .then(function(token){
                const recaptchaToken=document.querySelector("[name=recaptchaToken]");
                recaptchaToken.value=token;
                if(count<4){
                    setTimeout(getToken,timer)
                }
                    count++;
            })
            .catch(function(e){
                alert("reCAPTCHA token取得時にエラーが発生したためフォームデータを送信できません");
                return false
            });
        });
    }
    document.addEventListener("DOMContentLoaded",getToken);

}
```

ただこの方法では、確認画面でずーっと止まってしまっている場合、何度もトークンを取得しに行くのは色々まずいので、取得する回数を初回含めて３回としました。
そうすれば、2分×3 = 6分となり、6分ほど確認ページにて止まっていても問題なく送信できるようにしています。

また、dialog 要素などで```「送信するためのトークンの期限切れとなります。OKをクリックして再度取得してください。」```などと表示して、OKボタンをクリックすると再取得をするという方法もよいかもしれません。

上記のスクリプトを別ファイルとして読み込ませても良いですが、読み込みエラーやタイミングなどを考えて下記のようにインラインとして読み込む方が良いかもしれません。

```php:functions.php

$siteKey = 'XXXXXX';//サイトキー
$loadReCaptcha = 'https://www.google.com/recaptcha/api.js?render=' . $siteKey;
wp_enqueue_script('reCAPTCHv3',$loadReCaptcha,array(),'v3',true);


$addTokenUsePreventDefault = '
//ここにJavaScriptを記述する
 ';

wp_add_inline_script('reCAPTCHv3',$addTokenUsePreventDefault);

```


# まとめ

これでとりあえずはreCAPTCHAとして動作することはできました。

```php:functions.php
/*
 * MW WP Formの自作バリテーション
 */
function mwform_validation_recaptcha( $validation_rules ) {

    if(! class_exists('MW_Validation_Recaptcha')) {

        
        class MW_Validation_Recaptcha extends MW_WP_Form_Abstract_Validation_Rule {
            /*
             * 独自のバリデーションルール名を設定します。
             * 他のバリテーションを被らない名前が良いです。
             */
            protected $name = 'mwformrecaptcha';

             public function rule( $item_name, array $options = array() ) {

               //データが送信されていない（最初にフォームページが表示された）時は以下の処理をしない設定
                if( strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST' ) return '';
                
                /*
                 * 取得したトークンを取得
                 */
                $token = $this->Data->get($item_name);
                $token = !empty($token) ? $token : '';
                
                /*
                 * 管理画面のreCAPTCHAのバリテーションが設定されているかのチェックして変数にいれる
                 */
                $is_reCAPTCHA = isset($options['is_reCAPTCHA']) ? $options['is_reCAPTCHA'] : false;
                
                $secret_key = 'XXXXXXXXXX';//ここには取得したシークレットキーを記載します
                $threshold_score = 0.5;//閾値の設定
                
                //管理画面にてreCAPTCHAバリテーションのチェックがあった時の処理
                if($is_reCAPTCHA !== false) {
                    
                    if($item_name === 'recaptchaToken' && !isset($_POST['submitBack'])) {
                        
                        if(!isset($secret_key) || $secret_key === '') {
                            $defaults = array(
                                'message' => __('No reCAPTCHA Secret Key','efc-theme')
                            );
                            $options = array_merge($defaults,$options);
                            return $options['message'];
                        }
                        //Google reCAPTCHA APIに投げて判定してもらう設定
                        $url = 'https://www.google.com/recaptcha/api/siteverify?secret=' . $secret_key . '&response=' . $token;
                        $response = wp_remote_get($url);
                            if (!is_wp_error($response) && $response["response"]["code"] === 200) {
                                
                                $reCAPTCHA = json_decode($response["body"]);

                                if($reCAPTCHA->success) {
                                    if( $reCAPTCHA->score < $threshold_score) {
                                        
                                        $defaults = array(
                                            'message' => 'reCAPTCHAがスパムロボットと判断しましたので送信できませんでした!!'
                                        );
                                        $options = array_merge($defaults,$options);
                                        return $options['message'];
                                    }
                                } else {

                                    $defaults = array(
                                        'message' =>'reCAPTCHAがスパムロボットと判断しましたので送信できませんでした'
                                    );
                                    $options = array_merge($defaults, $options);
                                    return $options['message'];                                    
                                    
                                }
                                // \$reCAPTCHA->success
                                
                            } else {

                                $defaults = array(
                                    'message' => __('Faild reCAPtCHA Access','efc-theme')
                                );
                                $options = array_merge($defaults,$options);
                                return $options['message'];

                            }

                    }
                }
             }

            /*
             * フォーム編集画面の「バリデーションルール」に設定を追加する
             */
            public function admin($key, $value) {
                $is_reCAPTCHA = false;
                if (is_array($value[$this->getName()]) && isset($value[$this->getName()]['is_reCAPTCHA'])) {
                    $is_reCAPTCHA = $value[$this->getName()]['is_reCAPTCHA'];
                }
        ?>      
                <table>
                    <tr>
                        <td>reCAPTCHA V3</td>
                        <td><input type="checkbox" value="1" name="<?php echo MWF_Config::NAME; ?>[validation][<?php echo $key; ?>][<?php echo esc_attr($this->getName()); ?>][is_reCAPTCHA]" <?php if ($is_reCAPTCHA) : ?>checked<?php endif; ?> /></td>
                    </tr>
                </table>
        <?php
            }
        }
    }

    //上記ルールのインスタンスを作って返す
    if(!isset($instance)) {
        $instance = new MW_Validation_Recaptcha();
        $validation_rules[$instance->getName()] = $instance;
        return $validation_rules;
    }
}

add_filter('mwform_validation_rules','mwform_validation_recaptcha',20,1);

$siteKey = 'XXXXXX';//サイトキー
$loadReCaptcha = 'https://www.google.com/recaptcha/api.js?render=' . $siteKey;
wp_enqueue_script('reCAPTCHv3',$loadReCaptcha,array(),'v3',true);


$addTokenUsePreventDefault = '
if(document.querySelector(".mw_wp_form_input form")) {
    const myForm = document.querySelector(".mw_wp_form_input form");
    let preventEvent = true;
    const getToken =  (e) => {
        const target = e.target;
        if(preventEvent) {
            e.preventDefault();
            grecaptcha.ready(function() {
                grecaptcha.execute("'. $siteKey .'", {action: "homepage"})
                .then(function(token) {
                    preventEvent = false;
                    if(document.querySelector("[name=recaptchaToken]")) {
                        const recaptchaToken = document.querySelector("[name=recaptchaToken]");
                        recaptchaToken.value = token;
                    }
                    if(myForm.querySelector("[name=submitConfirm]")) {
                        const confirmButtonValue = myForm.querySelector("[name=submitConfirm]").value;
                        const myComfirmButton = document.createElement("input");
                        myComfirmButton.type = "hidden";
                        myComfirmButton.value = confirmButtonValue;
                        myComfirmButton.name = "submitConfirm";
                        myForm.appendChild(myComfirmButton);
                    }

                    myForm.submit();
                })
                .catch(function(e) {
                    alert("reCAPTCHA token取得時にエラーが発生したためフォームデータを送信できません");
                    return false;
                });
            });

        }

    }
    myForm.addEventListener("submit",getToken);

} else if(document.querySelector(".mw_wp_form_confirm form")){

    let count=0;
    const timer = 60 * 1000 * 2;
    getToken = () => {
        grecaptcha.ready(function(){
            grecaptcha.execute("'. $siteKey .'",{action:"homepage"})
            .then(function(token){
                const recaptchaToken=document.querySelector("[name=recaptchaToken]");
                recaptchaToken.value=token;
                if(count<4){
                    setTimeout(getToken,timer)
                }
                    count++
            })
            .catch(function(e){
                alert("reCAPTCHA token取得時にエラーが発生したためフォームデータを送信できません");
                return false
            });
        });
    }
    document.addEventListener("DOMContentLoaded",getToken);

}';

wp_add_inline_script('reCAPTCHv3',$addTokenUsePreventDefault);

```

上記はとりあえず動く形のものを記載しました。
本番で使う場合は要件定義に合わせてよりブラッシュアップする必要があると思います。

:::note info
確認画面から完了画面への遷移で、送信ボタンをクリック時にトークン取得する方法にした場合のトークンエラーの原因がわかりませんでした。
この辺で情報があればコメントにてお願いしたいです。
:::