'404',
'slug' => '',
];
/**
* Paths (relative to the site, lowercase) that stay reachable while logged
* out: front-end AJAX and form handlers, assets the login screen loads, core
* upgrade and install screens, and Application Passwords, which must reach
* auth_redirect() so the visitor lands on the custom login.
*/
const EXEMPT_PATHS = [
'/wp-admin/admin-ajax.php',
'/wp-admin/admin-post.php',
'/wp-admin/load-styles.php',
'/wp-admin/load-scripts.php',
'/wp-admin/upgrade.php',
'/wp-admin/install.php',
'/wp-admin/setup-config.php',
'/wp-admin/maint/repair.php',
'/wp-admin/authorize-application.php',
];
/**
* Result of ao_get_effective_login_slug() for this request.
*
* @var array{slug:string,source:string,enabled:bool,error:string}
*/
private $effective = [
'slug' => '',
'source' => 'db',
'enabled' => false,
'error' => '',
];
/**
* Check permalink if have trailing slash
*
* @var bool
*/
private $has_trailing_slash = false;
/**
* Placeholder for wp-login parameter
*
* @var bool
*/
private $wp_login_php;
const MENU_SLUG = 'adminoptimizer-custom-login-url';
/**
* Start the engine running
*
* @return void
*/
protected function init(): void {
// A safe-save that ran out is reverted here, before the effective slug is computed, so this very request
// already sees the restored slug. One array-key read on the option that is already loaded.
if ( is_array( $this->options ) && isset( $this->options[ Custom_Login_Url_Safe_Save::PENDING_KEY ] ) ) {
Custom_Login_Url_Safe_Save::maybe_expire();
$this->options = get_option( static::OPTION_NAME, [] );
add_action( Custom_Login_Url_Safe_Save::CRON_HOOK, [ Custom_Login_Url_Safe_Save::class, 'maybe_expire' ] );
}
// The settings object keeps the stored values; the constants only change what the runtime uses.
$this->settings = new Custom_Login_Url_Settings( $this->options );
$this->effective = ao_get_effective_login_slug( is_array( $this->options ) ? $this->options : [] );
$this->redirect = ao_get_login_redirect_target( is_array( $this->options ) ? $this->options : [] );
if ( $this->effective['enabled'] ) {
$this->options['custom_slug'] = $this->effective['slug'];
$this->has_trailing_slash = $this->check_permalink_trailing_slash();
$this->change_login_url();
add_action( 'wp_loaded', [ $this, 'maybe_redirect_load_url' ] );
add_filter( 'login_url', [ $this, 'login_url' ], 10, 3 );
add_filter( 'wp_redirect', [ $this, 'wp_redirect' ], 10, 2 );
add_filter( 'site_url', [ $this, 'site_url' ], 10, 4 );
remove_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 );
}
if ( is_admin() ) {
add_filter( Security_Monitor::SITE_HEALTH_ITEMS_FILTER, [ $this, 'add_site_health_item' ] );
add_action( 'adminoptim_module_state_changed', [ Custom_Login_Url_Safe_Save::class, 'on_module_state_changed' ], 10, 2 );
add_action( 'update_option_' . self::OPTION_NAME, [ $this, 'on_option_updated' ], 10, 2 );
add_action( 'add_option_' . self::OPTION_NAME, [ $this, 'on_option_added' ], 10, 2 );
if ( '' !== $this->effective['error'] ) {
add_action( 'admin_notices', [ $this, 'render_invalid_constant_notice' ] );
}
}
}
/**
* Option updated: email the new login URL when the slug changed.
*
* @param mixed $old_value Previous option value.
* @param mixed $new_value New option value.
*
* @return void
*/
public function on_option_updated( $old_value, $new_value ): void {
// The safe-save's own writes (confirm, revert) are not a user change: no cron change, no "changed" email.
if ( Custom_Login_Url_Safe_Save::is_internal_write() ) {
return;
}
Custom_Login_Url_Safe_Save::sync_schedule( $old_value, $new_value );
$this->maybe_email_login_url( $old_value, $new_value );
}
/**
* Option created (first save): treated as a change from "no custom slug".
*
* @param string $option Option name.
* @param mixed $value New option value.
*
* @return void
*/
public function on_option_added( $option, $value ): void {
if ( Custom_Login_Url_Safe_Save::is_internal_write() ) {
return;
}
Custom_Login_Url_Safe_Save::sync_schedule( [], $value );
$this->maybe_email_login_url( [], $value );
}
/**
* Email the full login URL to the site admin when the stored slug changed.
*
* Skipped when the setting is off, the slug is unchanged or empty, or a
* wp-config.php constant controls the value. Only the slug is sent: no
* credentials or tokens.
*
* @param mixed $old_value Previous option value.
* @param mixed $new_value New option value.
*
* @return void
*/
private function maybe_email_login_url( $old_value, $new_value ): void {
$old_slug = is_array( $old_value ) && isset( $old_value['custom_slug'] ) ? (string) $old_value['custom_slug'] : '';
$new_slug = is_array( $new_value ) && isset( $new_value['custom_slug'] ) ? (string) $new_value['custom_slug'] : '';
if ( '' === $new_slug || $new_slug === $old_slug || 'db' !== $this->effective['source'] ) {
return;
}
// A missing key means "on": installs that predate the setting get it by default.
if ( isset( $new_value['email_on_change'] ) && empty( $new_value['email_on_change'] ) ) {
return;
}
self::send_login_url_email( $new_slug );
}
/**
* The module's guide address. It is the `website` of the module's entry in
* Module_Registry, so there is one definition for the whole plugin; the
* settings help, the emails and Site Health all read it from here.
*
* @return string
*/
public static function get_docs_url(): string {
$module = Module_Registry::get_module( self::MODULE_ID );
return is_array( $module ) && ! empty( $module['website'] ) ? (string) $module['website'] : '';
}
/**
* Email the login URL to the site admin. The one place the message is built,
* shared by the settings-screen save, the safe-save revert and
* `wp ao login-url --notify`. Only the slug in force and recovery help are
* sent: no credentials, tokens, or the slug that was replaced.
*
* Variants: 'changed' (an empty slug is the "default login restored"
* message sent by `reset`) and 'reverted' (an unconfirmed change was undone;
* an empty slug means the default login is back).
*
* @param string $slug Slug now in force, or '' for the default login.
* @param string $variant 'changed' (default) or 'reverted'.
* @param int $minutes For 'reverted': minutes the change had to be confirmed.
*
* @return bool Whether wp_mail() accepted the message.
*/
public static function send_login_url_email( string $slug, string $variant = 'changed', int $minutes = 0 ): bool {
$admin_email = get_option( 'admin_email' );
if ( ! is_email( $admin_email ) ) {
return false;
}
$site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
// The default login URL, built without site_url( 'wp-login.php' ), which this module rewrites to the custom one.
$default_url = trailingslashit( site_url() ) . 'wp-login.php';
$recovery = "define( 'AO_DISABLE_CUSTOM_LOGIN', true );";
if ( 'reverted' === $variant ) {
/* translators: %s: site name. */
$subject = sprintf( __( '[%s] Your new login URL was not confirmed and was reverted', 'admin-optimizer' ), $site_name );
$message = sprintf(
/* translators: 1: site name, 2: minutes, 3: the login URL now in force, 4: wp-config.php line, 5: documentation URL. */
__( "The change to a new login URL for %1\$s was not confirmed within the %2\$d-minute limit, so it was reverted.\n\nThe login URL in force now:\n\n%3\$s\n\nTo try again, save the new login URL in Admin Optimizer, open the check link it shows in a new tab, and keep that tab's page loading.\n\nIf you are locked out, add this line to your wp-config.php to restore the default login, then log in at wp-login.php again:\n\n%4\$s\n\nMore help: %5\$s\n\nThis message was sent by Admin Optimizer. It does not contain your password.", 'admin-optimizer' ),
$site_name,
$minutes,
'' === $slug ? $default_url . ' ' . __( '(the default WordPress login)', 'admin-optimizer' ) : self::build_url( $slug ),
$recovery,
self::get_docs_url()
);
} elseif ( '' === $slug ) {
/* translators: %s: site name. */
$subject = sprintf( __( '[%s] Your login URL was reset', 'admin-optimizer' ), $site_name );
$message = sprintf(
/* translators: 1: site name, 2: the default login URL, 3: wp-config.php line, 4: documentation URL. */
__( "The custom login URL for %1\$s was removed. The default login page works again:\n\n%2\$s\n\nIf you set a custom login URL again and get locked out, add this line to your wp-config.php to restore the default login:\n\n%3\$s\n\nMore help: %4\$s\n\nThis message was sent by Admin Optimizer. It does not contain your password.", 'admin-optimizer' ),
$site_name,
$default_url,
$recovery,
self::get_docs_url()
);
} else {
/* translators: %s: site name. */
$subject = sprintf( __( '[%s] Your login URL has changed', 'admin-optimizer' ), $site_name );
$message = sprintf(
/* translators: 1: site name, 2: the new login URL, 3: wp-config.php line, 4: documentation URL. */
__( "The login URL for %1\$s has changed. Your new login URL is:\n\n%2\$s\n\nBookmark it now. The old wp-login.php address no longer shows the login page.\n\nIf you are locked out, add this line to your wp-config.php to restore the default login, then log in at wp-login.php again:\n\n%3\$s\n\nMore help: %4\$s\n\nThis message was sent by Admin Optimizer. It does not contain your password.", 'admin-optimizer' ),
$site_name,
self::build_url( $slug ),
$recovery,
self::get_docs_url()
);
}
return (bool) wp_mail( $admin_email, $subject, $message );
}
/**
* Admin notice: AO_CUSTOM_LOGIN_URL was blocked by the validator, so the module is off.
*
* @return void
*/
public function render_invalid_constant_notice(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
AO_CUSTOM_LOGIN_URL',
esc_html( Custom_Login_Url_Validator::get_messages()[ $this->effective['error'] ] ?? $this->effective['error'] )
),
[ 'code' => [] ]
);
?>
true ] ) );
$collides = $page instanceof \WP_Post && 'publish' === $page->post_status;
}
// Plugin detection runs only here, when Site Health rows are built, never on a normal request.
$items[] = self::build_site_health_item( $options, $collides, self::get_module_admin_url(), '' === $slug ? [] : Custom_Login_Url_Compat::detect_conflicts() );
return $items;
}
/**
* Build the Site Health row from the saved options. Pure, so every rule
* can be tested. The tab cannot confirm from outside that wp-login.php is
* really hidden (server rewrites and other plugins are not handled, and
* loopback checks are out of scope), so a good result says "configured",
* never "hidden".
*
* @param array $options Saved `adminoptim_custom_login` option.
* @param bool $collides Whether a published page/post already uses the custom slug.
* @param string $url Link to the module's own screen.
* @param array $conflicts Names of active plugins that also change the login URL.
*
* @return array
*/
public static function build_site_health_item( array $options, bool $collides, string $url, array $conflicts = [] ): array {
$slug = isset( $options['custom_slug'] ) ? (string) $options['custom_slug'] : '';
$target = ao_get_login_redirect_target( $options );
$current = '';
$recommended = '';
$reason = '';
$link_label = __( 'Open module', 'admin-optimizer' );
$status = 'warning';
// Same rules and lists as the settings screen; the content lookup is passed in as $collides.
$result = '' === $slug ? null : Custom_Login_Url_Validator::validate(
$slug,
[
'redirection_slug' => $target['slug'],
'check_content' => false,
]
);
if ( '' === $slug ) {
$message = __( 'The module is on, but no login URL is saved yet, so wp-login.php is unchanged.', 'admin-optimizer' );
$current = __( 'Nothing saved', 'admin-optimizer' );
$recommended = __( 'Save a login URL', 'admin-optimizer' );
$reason = __( 'The form shows an example value, but nothing changes until you save it once.', 'admin-optimizer' );
$link_label = __( 'Save the settings', 'admin-optimizer' );
} elseif ( $conflicts ) {
/* translators: %s: plugin names, e.g. "WPS Hide Login, LoginPress". */
$message = sprintf( __( 'Another plugin also changes the login URL: %s. Two plugins doing this can lock you out.', 'admin-optimizer' ), implode( ', ', $conflicts ) );
$current = implode( ', ', $conflicts );
$recommended = __( 'Only one plugin changing the login URL', 'admin-optimizer' );
$reason = __( 'Deactivate one of them, or the login page may stop working.', 'admin-optimizer' );
$url = self::get_docs_url();
$link_label = __( 'Read the guide', 'admin-optimizer' );
} elseif ( 'collision_redirect' === $result['code'] ) {
/* translators: %s: the saved slug used for both URLs. */
$message = sprintf( __( 'The login URL and the redirection URL are both "%s".', 'admin-optimizer' ), $slug );
$current = $slug;
$recommended = __( 'Two different addresses', 'admin-optimizer' );
$reason = __( 'Blocked visitors are sent to the login page itself, which defeats the point of moving it.', 'admin-optimizer' );
$link_label = __( 'Change redirection URL', 'admin-optimizer' );
} elseif ( $collides || 'collision_base' === $result['code'] ) {
/* translators: %s: the saved login URL slug. */
$message = sprintf( __( 'The login URL "%s" is already used by a page or a WordPress path.', 'admin-optimizer' ), $slug );
$current = $slug;
$recommended = __( 'A slug that no page or WordPress path uses', 'admin-optimizer' );
$reason = __( 'That page or path would become unreachable, or the login page would not load there.', 'admin-optimizer' );
$link_label = __( 'Change login URL', 'admin-optimizer' );
} elseif ( Custom_Login_Url_Validator::STATUS_OK !== $result['status'] ) {
/* translators: 1: the saved login URL slug, 2: why it is a problem. */
$message = sprintf( __( 'The login URL "%1$s" is a poor choice. %2$s', 'admin-optimizer' ), $slug, $result['message'] );
$current = $slug;
$recommended = __( 'A unique word that is not a common login name', 'admin-optimizer' );
$reason = __( 'Bots probe /login, /admin and similar addresses first, so a guessable one adds little protection.', 'admin-optimizer' );
$link_label = __( 'Change login URL', 'admin-optimizer' );
} else {
$status = 'ok';
$message = __( 'Login URL is configured.', 'admin-optimizer' );
}
// A row has one link (the fix), so the guide goes in the explanation; the conflict row links to it directly.
if ( 'ok' !== $status && ! $conflicts ) {
/* translators: %s: documentation URL. */
$reason .= ' ' . sprintf( __( 'Guide: %s', 'admin-optimizer' ), self::get_docs_url() );
}
return [
'id' => 'custom-login-url',
'label' => __( 'Custom Login URL', 'admin-optimizer' ),
'message' => $message,
'status' => $status,
'current' => $current,
'recommended' => $recommended,
'reason' => $reason,
'url' => $url,
'link_label' => $link_label,
'priority' => 30,
];
}
/**
* The request path, normalized for matching: percent-decoded, lowercase,
* query string dropped, repeated slashes collapsed ("//wp-login.php"), and
* the home or site subdirectory removed. Always starts with "/", or is ''
* when there is no REQUEST_URI (CLI). Requests are matched on this path,
* never by searching the whole URL.
*
* @return string
*/
public static function get_request_path(): string {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return '';
}
$uri = rawurldecode( wp_unslash( $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared, never output.
$path = strtolower( (string) preg_replace( '#/+#', '/', (string) strtok( $uri, '?#' ) ) );
foreach ( [ home_url(), site_url() ] as $base_url ) {
$base = untrailingslashit( strtolower( (string) wp_parse_url( $base_url, PHP_URL_PATH ) ) );
if ( '' !== $base && ( $path === $base || 0 === strpos( $path, $base . '/' ) ) ) {
$path = substr( $path, strlen( $base ) );
break;
}
}
return '' === $path ? '/' : $path;
}
/**
* Whether a logged-out request must be left alone: not hidden, not
* redirected. The one place this is decided.
*
* Exempt by default: the paths in EXEMPT_PATHS, and the password-protected
* post form (POST to wp-login.php?action=postpass, nothing else).
*
* @param string|null $path Normalized request path; null reads the current request.
* @param string|null $method HTTP method; null reads the current request.
* @param string|null $action The `action` request parameter; null reads the current request.
*
* @return bool
*/
public static function is_exempt_request( ?string $path = null, ?string $method = null, ?string $action = null ): bool {
$path = $path ?? self::get_request_path();
$method = strtoupper( $method ?? ( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET' ) );
$action = $action ?? ( isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- compared to a fixed string only.
$exempt = in_array( $path, self::EXEMPT_PATHS, true )
|| ( '/wp-login.php' === $path && 'POST' === $method && 'postpass' === $action );
/**
* Filters whether a request is exempt from the hidden wp-login.php and wp-admin handling.
*
* @param bool $exempt True to leave the request alone.
* @param string $path Normalized request path, e.g. "/wp-admin/admin-post.php".
* @param string $method Upper-case HTTP method.
* @param string $action The `action` request parameter ('' if none).
*/
return (bool) apply_filters( 'ao_login_url_exempt_requests', $exempt, $path, $method, $action );
}
/**
* Send the headers that keep page caches and CDNs away from login-related
* responses (the custom login page, redirects and the 404). wp-login.php
* already calls nocache_headers(); the other two are what it lacks.
*
* @return void
*/
private function send_no_cache_headers(): void {
nocache_headers();
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- constant defined by page-cache plugins.
}
if ( ! headers_sent() ) {
header( 'X-LiteSpeed-Cache-Control: no-cache' );
}
}
/**
* What a hidden endpoint answers with, for a redirect mode. The same answer
* for wp-login.php and wp-admin: 404 mode never redirects.
*
* @param string $mode '404', 'home' or 'custom'.
*
* @return string 'render-404', 'redirect-home' or 'redirect-custom'.
*/
public static function get_hidden_response_plan( string $mode ): string {
if ( 'home' === $mode ) {
return 'redirect-home';
}
return 'custom' === $mode ? 'redirect-custom' : 'render-404';
}
/**
* Answer a logged-out request for a hidden endpoint, per the redirect mode.
* 404 mode renders the theme's 404 inline for wp-login.php and wp-admin alike
* (one code path, so the two cannot be told apart); home and custom modes
* redirect (302, no-cache). Never returns.
*
* @return void
*/
private function respond_hidden_request(): void {
$plan = self::get_hidden_response_plan( $this->redirect['mode'] );
if ( 'render-404' === $plan ) {
$this->render_inline_404();
}
$this->send_no_cache_headers();
if ( 'redirect-home' === $plan ) {
$url = home_url( '/' );
} else {
$url = self::build_url( $this->redirect['slug'], null, $this->has_trailing_slash );
}
wp_safe_redirect( $url, 302 );
die();
}
/**
* Make is_admin() answer false for the rest of this request.
*
* Scripts under wp-admin define WP_ADMIN, which sends WordPress and themes down
* admin-only paths (the admin bar calls get_current_screen(), which does not
* exist here, and block styles are registered differently). is_admin() asks a
* current screen first, so hand it one that says "front end". Done as early as
* plugins_loaded for a wp-admin request that carries no login cookie, the 404
* is built from the same state as the one for wp-login.php.
*
* @return void
*/
private function use_front_screen(): void {
if ( ! is_admin() ) {
return;
}
$GLOBALS['current_screen'] = new class() { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
/**
* Stand-in for WP_Screen::in_admin(): never in an admin area.
*
* @param string|null $admin Area being asked about.
*
* @return bool
*/
public function in_admin( $admin = null ) {
return false;
}
};
}
/**
* Render the theme's 404 for a hidden endpoint: status 404, no-cache
* headers, no Location header, no dependency on any page slug.
*
* This runs on wp_loaded, so the main query has not been parsed yet: the
* request is swapped for a junk path (as change_login_url() does for
* wp-login.php), wp() builds the query for it, and the 404 state is then set
* explicitly. template-loader.php picks the theme's 404 template (block
* themes included) and ends the request, which also answers HEAD with the
* headers only. A site with no usable theme gets a minimal wp_die() 404.
*
* @return void
*/
private function render_inline_404(): void {
global $pagenow, $wp_query;
$this->send_no_cache_headers();
$_SERVER['REQUEST_URI'] = $this->user_trailingslashit( '/' . str_repeat( '-/', 10 ) );
$pagenow = 'index.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// WordPress builds its query from the entry script too; without this wp-login.php and wp-admin
// would each produce a differently built 404 page.
$_SERVER['PHP_SELF'] = '/index.php';
$_SERVER['SCRIPT_NAME'] = '/index.php';
if ( ! defined( 'WP_USE_THEMES' ) ) {
define( 'WP_USE_THEMES', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
}
$this->use_front_screen();
// The 404 must stay a 404: WordPress would otherwise "guess" a permalink for the made-up URL and
// redirect (wp-admin under a WordPress folder ends up at the home page).
add_filter( 'redirect_canonical', '__return_false' );
wp();
$wp_query->set_404();
status_header( 404 );
nocache_headers();
if ( '' === locate_template( [ '404.php', 'index.php' ] ) && ! wp_is_block_theme() ) {
wp_die( esc_html__( 'Page not found.', 'admin-optimizer' ), '', [ 'response' => 404 ] );
}
require_once ABSPATH . WPINC . '/template-loader.php';
die;
}
/**
* Change login url
*
* @return void
*/
public function change_login_url() {
global $pagenow;
$path = self::get_request_path();
// --- 1. wp-signup / wp-activate are not available on a single site. ---
if ( ! is_multisite() && preg_match( '#^/wp-(signup|activate)(\.php)?(/|$)#', $path ) ) {
wp_die( esc_html__( 'This feature is not enabled.', 'admin-optimizer' ) );
}
// --- 2. Exempt requests are left alone (see is_exempt_request()). ---
if ( self::is_exempt_request( $path ) ) {
return;
}
// --- 2b. A cookieless wp-admin request is logged out for certain: look front-end from the start. ---
if ( preg_match( '#^/wp-admin(/|$)#', $path ) && 'home' !== $this->redirect['mode'] && 'custom' !== $this->redirect['mode'] && ! isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
$this->use_front_screen();
}
if ( ! is_admin() && preg_match( '#^/wp-(login|register)(\.php)?(/|$)#', $path ) ) {
// --- 3. wp-login.php, wp-register.php: pretend the page does not exist. ---
$this->wp_login_php = true;
add_filter( 'adminoptim_is_login_page', '__return_true' );
$_SERVER['REQUEST_URI'] = $this->user_trailingslashit( '/' . str_repeat( '-/', 10 ) );
$pagenow = 'index.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
} elseif ( untrailingslashit( $path ) === '/' . $this->options['custom_slug']
|| ( ! get_option( 'permalink_structure' )
&& isset( $_GET[ $this->options['custom_slug'] ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
&& empty( $_GET[ $this->options['custom_slug'] ] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// --- 4. The custom slug: serve the login screen, never cached. ---
$pagenow = 'wp-login.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
add_filter( 'adminoptim_is_login_page', '__return_true' );
$this->send_no_cache_headers();
}
}
/**
* Redirect to user defined url
*
* @return void
*/
public function maybe_redirect_load_url() {
global $pagenow;
$path = self::get_request_path();
if ( self::is_exempt_request( $path ) ) {
return;
}
// --- 1. Logged-out wp-admin, by path as well as is_admin(), before auth_redirect() can leak the login URL. ---
if ( ( is_admin() || preg_match( '#^/wp-admin(/|$)#', $path ) ) && ! is_user_logged_in() && ! defined( 'DOING_AJAX' ) ) {
$this->respond_hidden_request();
}
if ( 'wp-login.php' === $pagenow && ( '/' === substr( $path, -1 ) ) !== $this->has_trailing_slash && get_option( 'permalink_structure' ) ) {
// --- 2. Custom slug without the permalink structure's trailing slash: canonical redirect. ---
wp_safe_redirect( $this->user_trailingslashit( $this->get_url() ) . ( ! empty( $_SERVER['QUERY_STRING'] ) ? '?' . wp_unslash( $_SERVER['QUERY_STRING'] ) : '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
die;
} elseif ( $this->wp_login_php ) {
// --- 3. Someone asked for the real wp-login.php: 404 or redirect, per the redirect mode. ---
$referer = (string) wp_get_referer();
if ( function_exists( 'wpmu_activate_signup' ) && false !== strpos( $referer, 'wp-activate.php' ) ) {
$referred_query = wp_parse_url( $referer );
if ( ! empty( $referred_query['query'] ) ) {
parse_str( $referred_query['query'], $referer );
if ( ! empty( $referer['key'] ) ) {
$result = wpmu_activate_signup( $referer['key'] );
if ( is_wp_error( $result ) && ( 'already_active' === $result->get_error_code() || 'blog_taken' === $result->get_error_code() ) ) {
wp_safe_redirect( $this->get_url() . ( ! empty( wp_unslash( $_SERVER['QUERY_STRING'] ) ) ? '?' . wp_unslash( $_SERVER['QUERY_STRING'] ) : '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
die;
}
}
}
}
$this->respond_hidden_request();
} elseif ( 'wp-login.php' === $pagenow ) {
// --- 4. The custom slug was requested: serve the real login screen. ---
global $error, $interim_login, $action, $user_login;
// A safe-save verification link proves the new URL loads. Confirm before wp-login.php can send an
// already logged-in admin away. The token grants nothing else.
if ( isset( $_GET['ao_verify'] ) && is_string( $_GET['ao_verify'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- an HMAC token, checked in constant time.
Custom_Login_Url_Safe_Save::confirm_with_token( sanitize_text_field( wp_unslash( $_GET['ao_verify'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// wp-login.php only recognises a session through the auth cookie, which the browser sends to
// wp-admin alone, so on the custom slug it would show the form. The logged-in cookie is sent
// site-wide: send an already logged-in admin on to the dashboard ourselves.
if ( is_user_logged_in() ) {
wp_safe_redirect( admin_url() );
die;
}
}
@require_once ABSPATH . 'wp-login.php'; //phpcs:ignore
die;
}
}
/**
* Retrieve user defined login url
*
* @param string $login_or_redirect Login or Redirect.
* @param string|null $scheme http or https.
*
* @return string
*/
private function get_url( $login_or_redirect = 'login', $scheme = null ) {
$slug = ( 'redirect' === $login_or_redirect ) ? $this->options['redirection_slug'] : $this->options['custom_slug'];
return self::build_url( $slug, $scheme, $this->has_trailing_slash );
}
/**
* Build the public URL for a slug. Shared by the runtime, the settings
* screen and the change email so they print the same address.
*
* @param string $slug Slug.
* @param string|null $scheme http or https.
* @param bool|null $trailing_slash Trailing slash; null reads the permalink structure.
*
* @return string
*/
public static function build_url( $slug, $scheme = null, $trailing_slash = null ) {
$structure = get_option( 'permalink_structure' );
if ( null === $trailing_slash ) {
$trailing_slash = '/' === substr( (string) $structure, -1, 1 );
}
if ( $structure ) {
$url = home_url( '/', $scheme ) . $slug;
if ( $trailing_slash ) {
$url = trailingslashit( $url );
}
} else {
$url = home_url( '/', $scheme ) . '?' . $slug;
}
return $url;
}
/**
* Check permalink trailing slash
*
* @return bool
*/
private function check_permalink_trailing_slash() {
return ( '/' === substr( get_option( 'permalink_structure' ), - 1, 1 ) );
}
/**
* Check user trailing slash
*
* @param string $text String to check.
*
* @return string
*/
private function user_trailingslashit( $text ) {
return $this->has_trailing_slash ? trailingslashit( $text ) : untrailingslashit( $text );
}
/**
* Redirection
*
* @param string $location Location to redirect to.
* @param string $status Status - not used.
*
* @return string
*/
public function wp_redirect( $location, $status ) {
if ( strpos( $location, 'https://wordpress.com/wp-login.php' ) !== false ) {
return $location;
}
return $this->filter_wp_login_php( $location );
}
/**
* Filter wp_login.php
*
* @param string $url Url to filter.
* @param string $scheme http or https.
*
* @return string
*/
public function filter_wp_login_php( $url, $scheme = null ) {
if ( strpos( $url, 'wp-login.php' ) !== false ) {
// The password-protected post form posts to wp-login.php?action=postpass. Rewriting it would print the
// custom login URL on public pages, so it stays as is and is exempted in is_exempt_request().
parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $url_args );
if ( isset( $url_args['action'] ) && 'postpass' === $url_args['action'] ) {
return $url;
}
if ( is_ssl() ) {
$scheme = 'https';
}
$args = explode( '?', $url );
if ( isset( $args[1] ) ) {
parse_str( $args[1], $args );
$url = add_query_arg( $args, $this->get_url( 'login', $scheme ) );
} else {
$url = $this->get_url( 'login', $scheme );
}
}
return $url;
}
/**
* Get the login url
*
* @param string $login_url Login url.
* @param string $redirect Redirect url.
* @param bool $force_reauth Force reauthentication.
*
* @return string|null
*/
public function login_url( $login_url, $redirect, $force_reauth ) {
if ( is_404() ) {
return '#';
}
$login_url = $this->filter_wp_login_php( $login_url );
if ( false === $force_reauth ) {
return $login_url;
}
if ( empty( $redirect ) ) {
return $login_url;
}
$redirect = explode( '?', $redirect );
if ( admin_url( 'options.php' ) === $redirect[0] ) {
$login_url = admin_url();
}
return $login_url;
}
/**
* Site Url
*
* @param string $url Site url.
* @param string $path Site path.
* @param string $scheme http or https.
* @param int $blog_id Blog id.
*
* @return mixed|string
*/
public function site_url( $url, $path, $scheme, $blog_id ) {
return $this->filter_wp_login_php( $url, $scheme );
}
}