WordPressのパスワード保護記事のログイン認証にreCAPTCHA v3を追加する

# はじめに
WordPressのプラグインには、管理画面へのログインや、パスワードリセット、記事のコメント投稿など、複数のフォームデータ送信シーンに対してGoogle reCAPTCHAを追加できるものがあります。

しかし、パスワードで保護された記事のログインフォームに対応しているプラグインは、有償のオプション提供のもの以外、私は見つけられませんでした。

このため、WordPressのパスワード保護記事のログイン認証に、reCAPTCHA v3を追加した時の手順をメモします。

# 前提
<システム環境>

- PHP 7.2
- WordPressバージョン 5.5.1
- WordPressテーマ Twenty Seventeen 2.4 (子テーマを作成してカスタマイズ)
- WordPressプラグイン SiteGuard 1.5.2
　(reCAPTCHAの動作に必要ではないのですが、インストールされている環境では考慮点があるため、記載しています)

<その他>

- Google reCAPTCHAキー取得済み。
- 機能は本来プラグイン化する方針だと思うのですが、今回はプラグイン化せず実装しています。

# 追加手順
Googleの[公式マニュアル](https://developers.google.com/recaptcha/docs/v3)中、「最も簡単な方法」で追加します。

##チャレンジをボタンに自動的にバインド
子テーマのfunctions.phpに下記コードを追加します。

```php:functions.php
/* GoogleRecaptcha V3 */
function recaptcha_v3() {
	wp_enqueue_script( 'recaptcha_v3', 'https://www.google.com/recaptcha/api.js' );
	wp_add_inline_script( 'recaptcha_v3', "function onSubmit(token) {
		document.getElementById('post-password-form').submit();
	}", 'after' );
}
add_action( 'wp_enqueue_scripts', 'recaptcha_v3' );
```
追加により、reCAPTCHAの動作に必要なJavaScriptがブラウザに読み込まれます。
※パスワード保護ページ以外でも読み込まれます。

getElementByIdの引数('post-password-form')は、パスワード保護ページのログインフォームのIDを指定しています。

ログインフォームのIDは、WordPress標準では明示されていません。このため、後述のコードもfunctions.phpへ追加します。

コード中のget_the_password_form_recaptcha関数は、WordPress標準のwp-includes/post-template.phpのget_the_password_form関数をコピーして変更を加えたもので、ログインフォームの生成を処理しています。

主な変更点は、下記の通りです。

- ログインフォームのIDを宣言する。
- ログインフォームのアクションの送信先をwp-login.phpからwp-loginpp.phpへ変更する。
- ログインフォームのログインボタンにreCAPTCHAの属性(サイトキー含む)を追加する。
- ログインフォームに隠し要素(input hidden)を追加し、g-recaptcha-actionという名前を付与し、アクション名を保持させる。

この変更後の関数をadd_filterの('the_password_form',...)に指定する事で、標準のログインフォームを上書きしています。

```php:functions.php
/**
 * Retrieve protected post password form content.
 *
 * @since 1.0.0
 *
 * @param int|WP_Post $post Optional. Post ID or WP_Post object. Default is global $post.
 * @return string HTML content for password form for password protected post.
 */
function get_the_password_form_recaptcha( $post = 0 ) {
	$post   = get_post( $post );
	$label  = 'pwbox-' . ( empty( $post->ID ) ? rand() : $post->ID );
	$output = '<form id="post-password-form" action="' . esc_url( site_url( 'wp-loginpp.php?action=postpass', 'login_post' ) ) . '" class="post-password-form" method="post">
	<p>' . __( 'This content is password protected. To view it please enter your password below:' ) . '</p>
	<p><label for="' . $label . '">' . __( 'Password:' ) . 
	' <input name="post_password" id="' . $label . '" type="password" size="20" /></label> <button class="g-recaptcha" data-sitekey="XXX" data-callback="onSubmit" data-action="POST_PASSWORD_FORM">' .
	esc_attr_x( 'Enter', 'post password form' ) . 
	'</button></p><p><input type="hidden" name="g-recaptcha-action" value="POST_PASSWORD_FORM"></p></form>';

	/**
	 * Filters the HTML output for the protected post password form.
	 *
	 * If modifying the password field, please note that the core database schema
	 * limits the password field to 20 characters regardless of the value of the
	 * size attribute in the form input.
	 *
	 * @since 2.7.0
	 *
	 * @param string $output The password form HTML output.
	 */
	return $output;
}
add_filter( 'the_password_form', 'get_the_password_form_recaptcha' );
```
<font color="MediumVioletRed">【要編集】</font>data-sitekey="XXX"のXXXは、事前に取得したreCAPTCHAサイトキーを指定して下さい。外部に公開されるため、間違ってもシークレットキーを指定しないようにご注意下さい。

【要注意】ログインフォームのアクション送信先のURLに
wp-login.phpを指定し、SiteGuardプラグインのログインページ変更機能でログインページURLを変更している場合、変更後のURLが自動的に代入されます。このため、外部から変更後のログインページURLが参照できる状態になるため、本記事ではwp-loginpp.phpと言うファイルを指定しています。ログインページ変更機能を有効にしていない場合も、サーバサイドでreCAPTCHAのスコア解釈を行うため、wp-loginpp.phpファイルの指定が必要です。

この時点で、ログインフォームを表示すると画面の右下に、reCAPTCHAの保護中アイコンが表示されます。

|![スクリーンショット 2020-10-27 16.13.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490699/8a9719fa-74da-2a49-105a-157a43d18ad5.png)|
|:--|
※画像は一部マスク処理しています。

また、reCAPTCHA管理コンソールを表示すると、サイトからのトラフィックが集計されていることが確認できます。(反映までタイムラグがあります)

|![スクリーンショット 2020-10-27 16.18.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490699/6eebb04e-23db-dc81-34a7-5f2998fd9b57.png)|
|:--|

しかし、サーバ側でスコアの判定処理を記述していないため、このままではチャレンジ結果に応じた処理が行えません。

## スコアの解釈
本記事では、スコアの解釈をwp-loginpp.phpファイルを作成して、処理しています。
wp-login.phpの処理にフィルターフックを設けて処理する方法も考えましたが、「図解『wp-login.php』！ WordPressのユーザ認証を極める！」[^1]の参考記事のフローを見る限り、パスワードで保護された投稿の認証フローへ適切に挟み込めるフックが見当たりませんでした。

また、WordPress 4.7から追加されたpost_password_requiredフィルタを使い、スコア解釈の判定処理を行う方法も検討しましたが、フック関数のためか、ブラウザからのPOSTリクエストを受け取れず、reCAPTCHAのスコア検証が行えませんでした。余談となりますが、post_password_requiredフィルタはとても便利で、参考記事[^2]で説明されている通り、パスワード保護ページに複数のパスワードを設定する事などが可能です。

## wp-loginpp.php
作成したwp-loginpp.phpファイルは下記の通りです。
WordPressインストールディレクトリ(wp-login.phpと同じディレクトリ)に配置します。

```php:wp-loginpp.php

// WordPress API
require_once ( dirname(__FILE__) . '/wp-load.php' );

// SiteGuardプラグインのフィルタ無効化
global $siteguard_rename_login;
remove_filter( 'login_init', array( $siteguard_rename_login, 'handler_login_init' ), 10 );

//
// reCAPTCHA判定処理
// 出典:https://www.webdesignleaves.com/pr/plugins/google_recaptcha.php
//

// シークレットキー
$secretKey = 'YYY';

// 認証アクション
$wp_action = isset( $_GET[ 'action' ] ) ? esc_html($_GET[ 'action' ]) : NULL;

// reCAPTCHA トークン
$token = isset( $_POST[ 'g-recaptcha-response' ] ) ? esc_html($_POST[ 'g-recaptcha-response' ]) : NULL;
// reCAPTCHA アクション名 
$action = isset( $_POST[ 'g-recaptcha-action' ] ) ? esc_html($_POST[ 'g-recaptcha-action' ]) : NULL;

if ( $token && $action && $wp_action === 'postpass' ) {
    // cURL セッションを初期化
    $ch = curl_init();
    // curl_setopt() により転送時のオプションを設定
    // API URL の指定
    curl_setopt( $ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify" );
    // POST メソッドを使う
    curl_setopt( $ch, CURLOPT_POST, true );
    // API パラメータの指定
    curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( array(
      'secret'   => $secretKey,   //シークレットキー
      'response' => $token        //トークン
    ) ) );
    // curl_execの返り値を文字列にする
    curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
    // 転送を実行してレスポンスを $api_response に格納
    $api_response = curl_exec( $ch );
    // セッションを終了
    curl_close( $ch );
    
    // レスポンスの $json（JSON形式）をデコード
    $result = json_decode( $api_response );
    
    // 結果の判定処理
    if( isset($result->success) && $result->success && 
        isset($result->action) && $result->action === $action && 
        $result->score >= 0.5
    ) {
        // success が true でアクション名が一致し、スコアが 0.5 以上の場合は合格

        // wp_login.phpをロード
        require_once ( dirname(__FILE__) . '/wp-login.php' );
    } else {
        // ログインエラーページへリダイレクト
        wp_safe_redirect( esc_url( site_url( 'login-error' ) ) );
    }
} else {
    // ログインエラーページへリダイレクト
    wp_safe_redirect( esc_url( site_url( 'login-error' ) ) );
}
```
<font color="MediumVioletRed">【要編集】</font>$secretKey = 'YYY';のYYYは、事前に取得したreCAPTCHAシークレットキーを指定して下さい。

プログラム中のコメント(出典:)にもある通り、reCAPTCHA判定処理は「Google reCAPTCHA の使い方（v2/v3）」[^3]の参考記事より、ほぼコピーして作成しております。参考記事では、シークレットキーは外部ファイルに格納して、requireする実装になっています。

このプログラムは、大まかに下記の処理を行っています。

- require_onceでWordPress APIを利用可能にします。
- SiteGuardプラグインのログインページ変更機能で使用されるフィルタ 'login_init' を無効化します。有効化されている場合、後述のwp-login.phpをロードした後、URLが無効化されてしまい、404エラーとなります。リダイレクトではなく、サーバーサイドによるrequire_onceによるwp-login.phpの読み込みでもフィルタの対象になります。なお、無効化したフィルタは、次回URL読み込み時には自動で有効化されていたため、本プログラムでは有効化の処理は記述していません。
- URLのGETのactionがpostpass(パスワード保護ページの認証)かを判定します。この判定がないと、不正なURLを指定された場合にログイン画面が表示される恐れがあります。
- reCAPTCHAのスコア判定処理を実行します。ここでは「success が true でアクション名(ログインフォームの隠し要素の値)が一致し、スコアが 0.5 以上の場合は合格」としています。アクション名の比較はPOST値とAPI戻り値を比較しているだけのため、POST値を詐称されれば意味がないかも知れません。気休め程度に割り切っています。
- 合格ではない場合は全て、固定ページのsite_url/login-errorへリダイレクトしています。

## エラーページ
WordPressの固定ページで、エラーページを作成します。
URLはsite_url(サイトのベースURL)/login-errorですが、前述のwp-loginpp.phpのリダイレクト先を変更すれば、どのようなURLでも対応可能です。
今回の記事では、reCAPTCHAエラー時の詳細なメッセージを出力する処理は実装せず、単純にreCAPTCHAエラーとだけ表示しています。

|![スクリーンショット 2020-11-09 21.48.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490699/f3a87ef6-5bec-e741-f8f1-eaeaa252ca22.png)|
|:--|

なお、本エラー画面は、ログインフォームを経由せずに直接site_url/wp-loginpp.phpへアクセスした場合にも表示されます。これにより、SiteGuardプラグインでログインページURLを隠しながらも、パスワード保護記事のログイン認証を実行させる事が可能です。

# その他の考慮点

- ブラウザのJavaScriptが無効化されている場合、無条件でreCAPTCHAエラーとなります。JavaScriptが無効化されている場合は、有効化するようにアナウンスを設けるのがフレンドリーかも知れません。
- reCAPTCHA v3による判定に合格しなかった場合は、v2のチャレンジを表示する仕組みを考えても良いかも知れません。

# 終わりに
今回はWordPressのパスワード保護記事のログイン認証にreCAPTCHA v3を追加する方法を記載しましたが、これがベストプラクティスであるとは残念ながら思っていません。よりより方法がありましたら、ご指摘いただければ幸いです。

# 参考

[^1]: https://ka2.org/illustrated-wp-login-php-to-understand-wordpress-user-authentication/
[^2]: https://nendeb.com/560
[^3]: https://www.webdesignleaves.com/pr/plugins/google_recaptcha.php