前章までの施策によって、フロントエンド側に出力されるコンテンツ系ページからWordPressっぽさを排除できた。だが、ログイン画面などの認証系周りは未施策なため、この章ではその辺の秘匿化を行なっていく。

 まず、現状の確認だが、今までの施策によって、WEBサイトのドキュメントルート直下にはWordPressのログイン画面であるwp-login.phpは存在していない。そのため、仮にhttps://{Site_Domain_Name}/wp-login.phpに直接アクセスされたとしても、HTTP CODEとして404 NOT FOUNDが返される。しかし、WordPressのインストールパスを含めたhttps://{Site_Domain_Name}/app/wp-login.phpにアクセスするとログイン画面が表示されてしまう。サイトの秘匿化にあたっては、すべからくwp-login.phpの存在を隠蔽してしまうのが最適解となる。

wp-login.phpの存在を消す

 このファイルがあるだけで、そのサイトがWordPress建てであることがバレてしまう。さらには、このファイル名によるログイン画面の存在は、不正アクセスやクラッキングの温床になるのでデメリットしかない。であれば、もう一律でwp-login.phpへのアクセスを禁止して、その存在を亡きものにしてしまおう。
 .htaccessを下記のように変更する。

# ---
# 7. wp-login.phpへのアクセスは、接続元に依らず一律で404レスポンスを返す
# ---
RewriteRule wp-login\.php(.*)$ - [R=404,L]

 これで、URLにwp-login.phpが含まれるアクセスはすべて404となる。
 同時に、誰もWordPressにログインできなくなる(笑)。

新たなログイン用URLを作る

 では、wp-login.phpに代る新しいログイン画面を作っていこう。この作り方には様々な方法があるが、今回はwp-login.phpをラップして新しいログイン用URLを作る方法を紹介する。
 この方法は、Login rebuilderプラグイン等で採用されているやり方で、WordPressのログイン画面URLを変更する方法としては結構メジャーなものではないだろうか。
 まず、ドキュメントルートに新しいログイン画面となるPHPファイルを作ろう。今回のチャレンジではわかりやすくentrance.phpとする。実際に運用するWebサイトでは、憶えられづらいハッシュ値のような文字列のファイル名にしておくと、セキュリティ的に有用だろう。

$ cd {Document_Root_Path}
$ touch entrance.php

 作成したentrance.phpの中身は、下記のようにする。

<?php
$allow_ips = [
    // ログイン画面にアクセスを許可するIPアドレスを定義(未定義の場合は全ての接続元を許可する)
    // cf. '123.123.123.123', ...
];
if ( empty( $allow_ips ) || ( ! empty( $allow_ips ) && in_array( $_SERVER['REMOTE_ADDR'], $allow_ips, true ) ) ) {
    define( 'LOGIN_PAGE_FILE', basename( $_SERVER['SCRIPT_FILENAME'] ) );
    define( 'LOGIN_CREDENTIAL', hash( 'sha512', $_SERVER['HTTP_HOST'] .':'. $_SERVER['DOCUMENT_ROOT'] ) );
    require_once dirname( __FILE__ ) .'/app/wp-login.php';
} else {
    require_once dirname( __FILE__ ) . '/app/wp-load.php';
    global $wp_query;
    $wp_query->set_404();
    status_header( '404' );
    nocache_headers();
    include( get_query_template( '404' ) );
    exit;
}

 これで、https://{Site_Domain_Name}/entrance.phpが新たなログイン画面となる。しかし、このままではまだログインできないので、conceal.phpに下記のフィルターフックとアクションフックを追加する必要がある。

// WordPressインストールディレクトリの定義
$_wp_install_dir = trim( str_replace( $_SERVER['DOCUMENT_ROOT'], '', ABSPATH ), '/' );
$_wp_install_dir .= ! empty( $_wp_install_dir ) ? '/' : '';
defined( 'WP_INSTALL_DIR' ) or define( 'WP_INSTALL_DIR', $_wp_install_dir );
// 新しいログインページのファイル名を定義
defined( 'LOGIN_PAGE_FILE' ) or define( 'LOGIN_PAGE_FILE', 'entrance.php' );

add_filter( 'login_url', function( $login_url, $redirect, $force_reauth ) {
    $login_url = str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $login_url );
    return $login_url;
}, 10, 3 );
add_filter( 'logout_url', function( $logout_url, $redirect ) {
    $logout_url = str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $logout_url );
    return $logout_url;
}, 10, 2 );
add_filter( 'register_url', function( $url ) {
    $url = str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $url );
    return $url;
}, 10 );
add_filter( 'lostpassword_url', function( $url ) {
    $url = str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $url );
    return $url;
}, 10 );
add_filter( 'site_url', function( $url, $path, $orig_scheme, $blog_id ) {
    if ( $path === 'wp-login.php' && ( is_user_logged_in() || strpos( $_SERVER['REQUEST_URI'], LOGIN_PAGE_FILE ) !== false ) ) {
        $url = str_replace( WP_INSTALL_DIR . $path, LOGIN_PAGE_FILE, $url );
    }
    return $url;
}, 10, 4 );
add_filter( 'wp_redirect', function( $location, $status ) {
    if ( strpos( $_SERVER['REQUEST_URI'], LOGIN_PAGE_FILE ) !== false ) {
        $location = str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $location );
    }
    return $location;
}, 10, 2 );

add_action( 'login_init', function() {
    // 新ログインページを認証
    if ( ! defined( 'LOGIN_CREDENTIAL' ) || hash( 'sha512', $_SERVER['HTTP_HOST'] .':'. $_SERVER['DOCUMENT_ROOT'] ) !== LOGIN_CREDENTIAL ) {
        status_header( 404 );
        exit;
    }
});

 これでentrance.phpでWordPressにログイン(ログアウト、登録、パスワードリマインドも可能)ができるようになった。この方法のメリットは設定が簡単なことと、新たにログイン画面としたphpファイルが従来のwp-login.phpと同じように取り扱えるため、例えWordPressコアがアップデートされたとしてもそのまま運用していける点だ。反面、この新ログイン画面は実体はあくまでwp-login.phpなので、wp-login.phpにおける数々の制約もそのまま継承される。これがデメリットでもあり、基本的にWordPressのwp-login.phpはテーマから独立しているコアファイルの一種であるため、かなりカスタマイズがしにくい特性を持っている。PHPファイル内でページのHTML生成も行われるため、コアソースに手を加えずにログインフォーム等のUIを変更するのには限界があるのだ。
 WordPressにログインできるユーザがWeb担当者だけなどの限定的なメンバーに限られる場合など、ログイン画面自体の見た目まで隠蔽する必要がないのであれば、この方法で十分目的を達成できるだろう。しかし、不特定多数のユーザを登録・ログインさせるようなサブスクライブ型のサイトでは、この方法だけでのサイト秘匿化は完全ではないためおすすめできない。

WordPressの固定ページとして新しいログイン画面を作る

 前項で設置した新たなログイン用URLをさらにラップする形で、公開用のログイン画面を作っていこう。正確にはログイン用のフォームを備えた固定ページを作成して、そのページをログイン画面として代替利用するのだ。これによって、カスタマイズし難いwp-login.phpのような制約を受けることなくログイン画面を自由にカスタマイズできるようになる。
 一方で、ログイン画面を公開するということは、その画面に対しての第三者からの攻撃を回避できなくなることでもあるため、サイト自体のセキュリティ性は低下してしまう。この点はwp-login.phpの秘匿化効果とは別軸の問題ではあるが、そのデメリットを理解したうえでログイン画面の公開はするべきである。

 まず、WordPressにログインしたら、「固定ページ」を「新規追加」する。今回のチェレンジでは、ページのタイトルを「サインイン」に、URLスラッグをsigninとした。この固定ページを公開すると、https://{Site_Domain_Name}/signin/でこのページにアクセスできるようになる。これが新しく公開するログイン画面となるのだ。
 さて次に、この新ログイン画面の固定ページにログイン用のフォーム等を設置する。この設置方法は様々あるが、今回はブロック方式とテンプレート方式の2通りを紹介しよう。

カスタムブロックとしてログインフォームを設置する

 まず一つ目は、WordPress 5.x以降で実装された新エディタ「Gutenberg」のブロック機能を使用してログイン用の固定ページにログインフォームを設置していくやり方だ。
 自分でオリジナルのカスタムブロック定義用のJavaScriptを準備して追加しても良いが、ReactやJSX記法の知見が必要になって来るうえに結構手間なので、今回のチャレンジではプラグインを使ってGutenbergのカスタムブロックを拡張する。早速、プラグイン「Advanced Gutenberg」をインストールして、有効化しよう。
 すると、投稿編集時に追加できるブロックに「Advanced Gutenberg」カテゴリが増えるので、そこから「Login/Register Form」を選ぶ(下図参照)。

Advanced Gutenberg: blocks

 あとは、適宜入力フィールドのラベル名などを必要に応じて変更すればOkだ。実際のフロントエンド側に表示されるページを確認してみると、下図のように表示されるはずだ。

Advanced Gutenberg: Login Form

 これで、この固定ページからWordPressにログインできるようになる。ただ、ユーザ登録やログアウト、パスワードリマインダの処理は不完全なので、それらの画面用の対応が必要になる。それぞれの対応方法も紹介しておこう。

ログインおよびログアウトのリダイレクト先の調整

 新しいログイン画面でログインを行うと、ログイン完了後に管理画面にリダイレクトされない(Adminbarは表示されるので、そこから管理画面へ遷移することは可能)。また、管理画面からログアウトした時は、entrance.phpへリダイレクトされてしまい、ラップされているwp-login.phpが表示されてしまう。これらのリダイレクト先を制御するために、conceal.phpへ下記のフィルターを追加しよう。

add_filter( 'login_redirect', function( $redirect_to, $request, $user ) {
    if ( isset( $user->roles ) && is_array( $user->roles ) ) {
        if ( in_array( 'administrator', $user->roles, true ) ) {
            return home_url( WP_INSTALL_DIR .'/wp-admin' );
        } else {
            return home_url( '/' );
        }
    } else {
        return $redirect_to;
    }
}, 10, 3 );
add_filter( 'logout_redirect', function( $redirect_to, $request, $user ) {
    return home_url( '/' );
}, 10, 3 );

 これで、管理者ユーザがログインした時は管理画面へ、それ以外のユーザがログインした時はWebサイトのホームURLへそれぞれリダイレクトされるようになる。また、ログアウト時はすべてのユーザがホームURLへリダイレクトされる。
 このリダイレクト先をカスタマイズすれば、特定ユーザのみログイン後はプロフィール画面へ飛ばすということ等も可能である。

ユーザ登録時のリダイレクト処理の調整

 wp-login.phpでのユーザ登録におけるリダイレクトは、アカウント存在チェックやユーザ作成の処理にてエラーになった際に画面出力を伴う仕様になっているため、リダイレクトだけを分離することができない。そこで、ユーザ登録のコア処理が開始する前にアクションフックで独自のルーティングを挿入する必要がある(ここら辺が、wp-login.phpのカスタマイズが難しい所以だ)。下記のアクションフックをconceal.phpに追加しよう。

add_action( 'login_form_register', function() {
    $http_post = ( 'POST' === $_SERVER['REQUEST_METHOD'] );
    if ( $http_post ) {
        if ( isset( $_POST['user_login'] ) && is_string( $_POST['user_login'] ) ) {
            $user_login = $_POST['user_login'];
        }
        if ( isset( $_POST['user_email'] ) && is_string( $_POST['user_email'] ) ) {
            $user_email = wp_unslash( $_POST['user_email'] );
        }
        $errors = register_new_user( $user_login, $user_email );
        if ( ! is_wp_error( $errors ) ) {
            // Redirection after completing to register user
            $redirect_to = ! empty( $_POST['redirect_to'] ) ? $_POST['redirect_to'] : home_url( 'register-complete' );
            wp_safe_redirect( $redirect_to );
            exit;
        }
    }
    wp_redirect( home_url( LOGIN_PAGE_SLUG .'/?login=failed' ) );
    exit;
});

 簡単に説明すると、wp-login.phpのユーザ登録処理を前倒ししてlogin_form_registerフックで行い、処理結果にてリダイレクトを行って、後続のコア処理をスキップさせるという建て付けだ。ただし、この対応だけでは「Advanced Gutenberg」プラグインのブロック側で、登録処理のエラーをキャッチできないので、エラー表示が正常にできないという問題は残る。これを改善するには「Advanced Gutenberg」プラグイン側の改修が必要になってくるが、それは今回のチャレンジの本筋ではないので除外する。
 さらに、WordPressのユーザ登録は初めにユーザ名とメールアドレスのみ認証し、認証完了後にログインパスワード設定用URLが記載されたメールを送信する仕様になっている。そのメールに記載されるURLもフィルタリングしておかないと、アクセスができないwp-login.phpのURLがメール通知されてしまい、誰もユーザ登録できない状況となってしまうのだ。
 まず、ユーザアカウントの認証完了画面へリダイレクトさせる必要がある(前述のソースのhome_url( 'register-complete' )の部分)ので、固定ページとして作成しよう。注意点としては、作成したページのURLスラッグをソース内に定義した値と同じくregister-completeにしておく必要があることだ。これで、ユーザ登録が成功すると下図のような画面にリダイレクトされるようになる。

Advanced Gutenberg: Register Complete

 次に、認証メールに記載されるURLをフィルタする。conceal.phpに下記を追加しよう。

add_filter( 'wp_new_user_notification_email', function( $wp_new_user_notification_email, $user, $blogname ) {
    // 登録時パスワード設定用メールの本文をフィルタ
    return str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $wp_new_user_notification_email );
}, 10, 3 );

 さらに、認証メールからアクセスされるパスワード設定画面のルーティングを行う。固定ページとして、パスワード設定用のURLスラッグがset-new-passwordのページとパスワード変更完了画面となるURLスラッグがreset-password-completeのページをそれぞれ作成する。本来、パスワード設定用のset-new-passwordのページにはパスワード入力フォームが必要なのだが、「Advanced Gutenberg」プラグインを使ってもこのフォームは作れないので、別に用意することにする。とりあえず、本文には通知メッセージと後ほどフォームを挿入するための枠だけを「カスタムHTML」ブロックで追加しておこう(下図参照)。

Gutenberg Block Editor: set-new-password page

 パスワード設定画面用のルーティングは、パスワードのバリデーション直前のアクションフックvalidate_password_resetに対して行う。

add_action( 'validate_password_reset', function( $errors, $user ) {
    list( $rp_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) );
    $rp_cookie       = 'wp-resetpass-' . COOKIEHASH;

    if ( ( ! $errors->has_errors() ) && isset( $_POST['pass1'] ) && ! empty( $_POST['pass1'] ) ) {
        reset_password( $user, $_POST['pass1'] );
        setcookie( $rp_cookie, ' ', time() - YEAR_IN_SECONDS, $rp_path, COOKIE_DOMAIN, is_ssl(), true );
        wp_safe_redirect( home_url( 'reset-password-complete' ) );
    } else {
        if ( isset( $_COOKIE[ $rp_cookie ] ) && 0 < strpos( $_COOKIE[ $rp_cookie ], ':' ) ) {
            list( $rp_login, $rp_key ) = explode( ':', wp_unslash( $_COOKIE[ $rp_cookie ] ), 2 );
        }
        wp_safe_redirect( home_url( 'set-new-password/?rp_login='. $rp_login .'&rp_key='. $rp_key ) );
    }
    exit;
}, 10, 2 );

 これで、認証メールのURLからアクセスすると固定ページのhttps://{Site_Domain_Name}/set-new-password/にリダイレクトされるようになる。仕上げとして、この固定ページに新規パスワード入力用のフォームを追加しよう。

add_filter( 'the_content', function( $content ) {
    if ( is_page( 'set-new-password' ) ) {
        $action_url  = esc_url( home_url( LOGIN_PAGE_FILE . '?action=resetpass' ) );
        $rp_login    = esc_attr( $_GET['rp_login'] );
        $rp_key      = esc_attr( $_GET['rp_key'] );
        $label_pass1 = __( 'New password' );
        $label_pass2 = __( 'Confirm new password' );
        $label_submit= __( 'Reset Password' );
        $resetpassform = <<<EOD
<form name="resetpassform" id="resetpassform" action="{$action_url}" method="post" autocomplete="off">
  <input type="hidden" id="user_login" value="{$rp_login}">
  <div>
    <label for="pass1">{$label_pass1}</label>
    <input type="password" name="pass1" id="pass1" size="24" value="" autocomplete="off">
  </div>
  <div>
    <label for="pass2">{$label_pass2}</label>
    <input type="password" name="pass2" id="pass2" size="24" value="" autocomplete="off">
  </div>
  <br>
  <input type="hidden" name="rp_key" value="{$rp_key}">
  <div>
    <button type="submit">{$label_submit}</button>
  </div>
</form>
EOD;
        $content = str_replace( '<form id="resetpassform"></form>', $resetpassform, $content );
    }
    return $content;
});

 これで、正常系のユーザ登録が可能になる。エラー時のルーティングは別途設定しないといけないが、ここまでのカスタマイズ手順とほぼ同じようなやり方で出来るはずだ。

パスワード再設定のリダイレクト処理の調整

 最後に、「パスワードをお忘れですか?」のリンクから辿るパスワード再設定のルーティングを調整していく。まず、パスワード再設定用の画面を固定ページで作成しよう。今回のチャレンジではlostpasswordというURLスラッグで作成する。このページも後ほど入力フォームを埋め込むので、「カスタムHTML」ブロックでフォーム枠だけ追加しておく(下図参照)。

Gutenberg Block Editor: lostpassword page

 そして、パスワード再設定用のルーティングを追加する。これはconceal.phplogin_form_lostpasswordlost_passwordの2つのアクションフックを利用する。

add_action( 'login_form_lostpassword', function() {
    $http_post = ( 'POST' === $_SERVER['REQUEST_METHOD'] );
    if ( $http_post ) {
        $errors = retrieve_password();
        if ( ! is_wp_error( $errors ) ) {
            // Redirection after completing to validate user
            $redirect_to = ! empty( $_REQUEST['redirect_to'] ) ? $_REQUEST['redirect_to'] : home_url( 'lostpassword-comfirm' );
            wp_safe_redirect( $redirect_to );
            exit;
        }
    } else {
        wp_safe_redirect( home_url( wp_unslash( str_replace( LOGIN_PAGE_FILE, 'lostpassword/', $_SERVER['REQUEST_URI'] ) ) ) );
        exit;
    }
});
add_action( 'lost_password', function( $errors ) {
    $lostpassword_redirect = ! empty( $_REQUEST['redirect_to'] ) ? $_REQUEST['redirect_to'] : '';
    $redirect_to = apply_filters( 'lostpassword_redirect', $lostpassword_redirect );
    $get_queries = [];
    if ( ! empty( $redirect_to ) ) {
        $get_queries[] = 'redirect_to='. esc_url( $redirect_to );
    }
    if ( $errors->has_errors() ) {
        $get_queries[] = 'error='. esc_attr( $errors->get_error_code() );
    }
    wp_safe_redirect( home_url( 'lostpassword/'. ( ! empty( $get_queries ) ? '?'. implode( '&', $get_queries ) : '' ) ) );
    exit;    
}, 10 );

 次に、パスワード再設定の固定ページに入力フォームを埋め込む。前項で追加したthe_contentフィルターフックを次のように拡張しよう。

add_filter( 'the_content', function( $content ) {
    if ( is_page( 'set-new-password' ) ) {
        (...省略...)
    } else
    if ( is_page( 'lostpassword' ) ) {
        if ( isset( $_REQUEST['error'] ) && ! empty( $_REQUEST['error'] ) ) {
            switch( $_REQUEST['error'] ) {
                case 'empty_username':
                    $message = __( '<strong>Error</strong>: Enter a username or email address.' );
                    break;
                case 'invalid_email':
                    $message = __( '<strong>Error</strong>: There is no account with that username or email address.' );
                    break;
                case 'invalidkey':
                    $message = __( 'Your password reset link appears to be invalid. Please request a new link below.' );
                    break;
                case 'expiredkey':
                    $message = __( 'Your password reset link has expired. Please request a new link below.' );
                    break;
                default:
                    $message = '';
                    break;
            }
            $error = sprintf( '<div><p class="error">%s</p></div>', $message );
        }
        $action_url  = esc_url( home_url( LOGIN_PAGE_FILE . '?action=lostpassword' ) );
        $label_user_login = __( 'Username or Email Address' );
        $user_login = esc_attr( ( isset( $_POST['user_login'] ) && is_string( $_POST['user_login'] ) ) ? wp_unslash( $_POST['user_login'] ) : '' );
        $lostpassword_redirect = esc_url( ( isset( $_REQUEST['redirect_to'] ) && ! empty( $_REQUEST['redirect_to'] ) ) ? $_REQUEST['redirect_to'] : '' );
        $label_submit= __( 'Get New Password' );
        $lostpasswordform = <<<EOD
{$error}
<form name="lostpasswordform" id="lostpasswordform" action="{$action_url}" method="post">
  <input type="hidden" id="user_login" value="{$rp_login}">
  <div>
    <label for="user_login">{$label_user_login}</label>
    <input type="text" name="user_login" id="user_login" value="{$user_login}" size="20" autocapitalize="off" />
  </div>
  <br>
  <input type="hidden" name="redirect_to" value="{$lostpassword_redirect}">
  <div>
    <button type="submit">{$label_submit}</button>
  </div>
</form>
EOD;
        $content = str_replace( '<form id="lostpasswordform"></form>', $lostpasswordform, $content );       
    }
    return $content;
}

 最後に、パスワード再設定でユーザ認証が成功した時に送信されるメールに記載されるパスワード再設定用のURLをフィルタリングする必要がある。これは、retrieve_password_messageのフィルターフックを追加して対応できる。

add_filter( 'retrieve_password_message', function( $message, $key, $user_login, $user_data ) {
    // パスワード変更時メールの本文をフィルタ
    $message = str_replace( WP_INSTALL_DIR . 'wp-login.php', LOGIN_PAGE_FILE, $message );
    return $message;
}, 10, 4 );

 これで完了だ。

テンプレートをインクルードしてログインフォームを設置する

 こちらは、使用しているテーマ側にログインフォーム用のページテンプレートファイルを準備し、ログイン用の固定ページ専用のテンプレートとして設定する方法だ。

(…現在、検証&執筆中…)