PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.8.1
Defender Security – Malware Scanner, Login Security & Firewall v5.8.1
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / controller / class-mask-login.php

class-mask-login.php in Defender Security – Malware Scanner, Login Security & Firewall 5.8.1, at src/controller/class-mask-login.php

1,217 lines 34.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles mask login.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use WP_User;
11 use WP_Query;
12 use WP_Admin_Bar;
13 use WP_Recovery_Mode;
14 use WP_Defender\Event;
15 use Calotes\Helper\HTTP;
16 use Calotes\Helper\Route;
17 use WP_Defender\Traits\IO;
18 use Calotes\Component\Request;
19 use Calotes\Component\Response;
20 use WP_Defender\Component\Two_Fa;
21 use WP_Defender\Traits\Permission;
22 use WP_Defender\Component\Blacklist_Lockout;
23 use WP_Defender\Component\Config\Config_Hub_Helper;
24 use WP_Defender\Component\Security_Tweaks\Servers\Server;
25 use WP_Defender\Component\Mask_Login as Component_Mask_Login;
26 use WP_Defender\Model\Setting\Mask_Login as Model_Mask_Login;
27
28 /**
29 * Handles mask login.
30 */
31 class Mask_Login extends Event {
32
33 use IO;
34 use Permission;
35
36 /**
37 * The model for handling the data.
38 *
39 * @var Model_Mask_Login
40 */
41 protected $model;
42
43 /**
44 * Service for handling logic.
45 *
46 * @var Component_Mask_Login
47 */
48 protected $service;
49
50 /**
51 * The compatibility notices.
52 *
53 * @var array
54 */
55 protected $compatibility_notices = array();
56
57 /**
58 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
59 */
60 public function __construct() {
61 add_filter( 'wp_defender_advanced_tools_data', array( $this, 'script_data' ) );
62 // Internal cache, so we don't need to query many times.
63 $this->model = wd_di()->get( Model_Mask_Login::class );
64 $this->service = wd_di()->get( Component_Mask_Login::class );
65 $this->register_routes();
66
67 if ( $this->get_model()->is_active() ) {
68 $auth_component = wd_di()->get( Two_Fa::class );
69 require_once ABSPATH . 'wp-admin/includes/plugin.php';
70 $is_jetpack_sso = $auth_component->is_jetpack_sso();
71 $is_tml = $auth_component->is_tml();
72 if ( $is_jetpack_sso || $is_tml ) {
73 if ( $is_jetpack_sso ) {
74 $this->compatibility_notices[] = esc_html__(
75 'We`ve detected a conflict with Jetpack`s Wordpress.com Log In feature. Please disable it and return to this page to continue setup.',
76 'defender-security'
77 );
78 }
79 if ( $is_tml ) {
80 $this->compatibility_notices[] = esc_html__(
81 'We`ve detected a conflict with Theme my login. Please disable it and return to this page to continue setup.',
82 'defender-security'
83 );
84 }
85
86 return;
87 }
88 // Monitor wp-admin, wp-login.php.
89 add_filter( 'wp_redirect', array( $this, 'filter_wp_redirect' ), 10 );
90 // Filter site_url & network_site_url so people won't get block screen.
91 add_filter( 'site_url', array( $this, 'filter_site_url' ), 100 );
92 add_filter( 'network_site_url', array( $this, 'filter_site_url' ), 100 );
93 // For prevent admin redirect.
94 remove_action( 'template_redirect', 'wp_redirect_admin_locations' );
95 // If Pro site is activated and user email is not defined, we need to update the email to match the new login URL.
96 add_filter( 'update_welcome_email', array( $this, 'update_welcome_email_prosite_case' ), 10, 6 );
97 add_filter( 'lostpassword_redirect', array( $this, 'change_lostpassword_redirect' ), 10 );
98 // Log links in email.
99 add_filter( 'report_email_logs_link', array( $this, 'update_report_logs_link' ), 10, 2 );
100 if ( class_exists( 'bbPress' ) ) {
101 add_filter( 'bbp_redirect_login', array( $this, 'make_sure_wpadmin_after_login' ), 10, 3 );
102 }
103
104 if ( 'flywheel' === Server::get_current_server() ) {
105 if ( ! is_user_logged_in() ) {
106 add_action( 'login_form_rp', array( $this, 'handle_password_reset' ) );
107 add_action( 'login_form_resetpass', array( $this, 'handle_password_reset' ) );
108 }
109 add_filter( 'retrieve_password_message', array( $this, 'flywheel_change_password_message' ), 10, 4 );
110 }
111
112 global $pagenow;
113 if ( is_network_admin() && 'sites.php' === $pagenow ) {
114 // Add 4th parameter $scheme when the plugin will support WP at least v5.8.
115 add_filter( 'admin_url', array( $this, 'change_subsites_admin_url' ), 10, 3 );
116 }
117
118 if ( is_admin() && 'my-sites.php' === $pagenow ) {
119 add_filter( 'myblogs_blog_actions', array( $this, 'update_myblogs_blog_actions' ), 10, 2 );
120 }
121
122 if ( is_multisite() ) {
123 add_action( 'admin_bar_menu', array( $this, 'update_admin_bar_menu' ), 100 );
124 }
125
126 if ( $this->service->is_set_locale( $this->model->mask_url ) ) {
127 add_action( 'init', array( $this, 'set_locale' ) );
128 }
129 // Never catch if from cli.
130 if ( ! defender_is_wp_cli() ) {
131 $this->before_mask_login_handle();
132 }
133 add_action( 'init', array( $this, 'handle_login_request' ), 99 );
134 }
135 }
136
137 /**
138 * Adjusts the redirect URL after login to ensure it redirects to the admin dashboard if the current URL is the
139 * home URL.
140 *
141 * @param string $url The current redirect URL.
142 * @param string $raw_url The raw redirect URL.
143 * @param object $user The user object.
144 *
145 * @return string The adjusted redirect URL after applying filters.
146 */
147 public function make_sure_wpadmin_after_login( string $url, string $raw_url, object $user ): string {
148 if ( home_url() === $url ) {
149 $url = admin_url();
150 }
151
152 return apply_filters( 'defender_redirect_login', $url, $raw_url, $user );
153 }
154
155 /**
156 * Show login page.
157 *
158 * @return void
159 */
160 public function show_login_page(): void {
161 global $error, $interim_login, $action, $user_login, $user, $redirect_to;
162 // Simulate the environment as a "login page".
163 $GLOBALS['pagenow'] = 'wp-login.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
164 if ( $this->service->is_recovery_mode() ) {
165 ( new WP_Recovery_Mode() )->initialize();
166 }
167 require_once ABSPATH . 'wp-login.php';
168 die;
169 }
170
171 /**
172 * Before Mask Login handling.
173 *
174 * @return void
175 * @since 2.8.0
176 */
177 public function before_mask_login_handle(): void {
178 // Some plugins for Cron actions clear HTTP_HOST-param.
179 $host = defender_get_data_from_request( 'HTTP_HOST', 's' );
180 if ( ! isset( $host['HTTP_HOST'] ) ) {
181 $host = '';
182 }
183 $current_url = set_url_scheme( 'http://' . $host . defender_get_data_from_request( 'REQUEST_URI', 's' ) );
184 $login_url = $this->get_model()->get_new_login_url( $this->get_site_url() );
185
186 if (
187 ! is_user_logged_in() &&
188 '' !== $login_url &&
189 ! $this->service->is_land_on_masked_url( $this->model->mask_url ) &&
190 /**
191 * Filter to redirect current URL to Mask Login URL.
192 *
193 * @param bool $allowed Should we redirect to Mask Login URL?.
194 * @param string $current_url Current URL to check.
195 *
196 * @since 2.8.0
197 */
198 true === apply_filters( 'wpdef_maybe_redirect_to_mask_login_url', false, $current_url )
199 ) {
200 $modified_url = add_query_arg( 'redirect_to', rawurlencode( $current_url ), $login_url );
201
202 wp_safe_redirect( $modified_url );
203 die();
204 }
205 }
206
207 /**
208 * Protect unauthorized login redirect.
209 *
210 * @param string $location The redirect URL.
211 *
212 * @return string
213 */
214 public function protect_unauthorized_login_redirect( string $location ) {
215 // Make sure that wp-login.php is never used in site URLs or redirects.
216 if ( ! $this->service->is_login_url() ) {
217 $redirect_path = trim( (string) wp_parse_url( $location, PHP_URL_PATH ), '/' );
218 if ( $redirect_path === $this->get_model()->mask_url ) {
219 $this->maybe_lock();
220 }
221 }
222
223 return $location;
224 }
225
226 /**
227 * If it is request to wp-admin, wp-login.php and similar slugs, we block for sure. If no, then follow the wp flow.
228 *
229 * @return void
230 */
231 public function handle_login_request() {
232 // If the IP is BLC whitelisted, then skip processing URLs other than the Masked Login URL.
233 if ( wd_di()->get( Blacklist_Lockout::class )->is_blc_ip_whitelisted() ) {
234 if ( $this->service->is_land_on_masked_url( $this->model->mask_url ) ) {
235 $this->show_login_page();
236 }
237
238 return;
239 }
240
241 // Need to check if the current request is for signup, login.
242 // If it is not the slug, then we redirect to the 404 redirect, or 403 wp die.
243 $requested_path = $this->service->get_request_path();
244 $requested_path_without_slash = ltrim( $requested_path, '/' );
245 if ( ! $requested_path_without_slash && '' !== get_option( 'permalink_structure', '' ) ) {
246 return;
247 } else {
248 $params = wp_parse_args( defender_get_data_from_request( 'QUERY_STRING', 's' ), array() );
249 if ( isset( $params[ $this->model->mask_url ] ) ) {
250 $this->show_login_page();
251 }
252 }
253
254 if ( '/' . ltrim( $this->get_model()->mask_url, '/' ) === $requested_path ) {
255 // We need to redirect this one to wp-login and open it.
256 return $this->show_login_page();
257 }
258 /**
259 * Allowed if:
260 * it's AJAX,
261 * the user is logged in,
262 * it's an admin post request.
263 */
264 if (
265 defined( 'DOING_AJAX' )
266 || is_user_logged_in()
267 || $this->is_allowed_path( $requested_path_without_slash )
268 ) {
269 // Do nothing.
270 return;
271 }
272
273 // If user is not logged in but login cookie is set.
274 $cookie = defender_get_data_from_request( null, 'c' );
275 if ( isset( $cookie[ LOGGED_IN_COOKIE ] ) && ! is_user_logged_in() ) {
276 $user_id = wp_validate_auth_cookie( $cookie[ LOGGED_IN_COOKIE ], 'logged_in' );
277
278 if ( $user_id ) {
279 // Cookie is valid so login the user.
280 wp_set_current_user( $user_id );
281
282 // Return from here because of valid user found.
283 return;
284 }
285 }
286
287 $ticket = HTTP::get( 'ticket', false );
288 // Todo: need if express_tickets are not saved?
289 if ( false !== $ticket && $this->service->redeem_ticket( $ticket ) ) {
290 // Allow to pass.
291 return;
292 }
293
294 // If current is same then we show the login screen.
295 if ( $this->service->is_land_on_masked_url( $this->model->mask_url ) ) {
296 return $this->show_login_page();
297 }
298
299 // If it's the verification link to change Network Admin Email.
300 $is_multisite = is_multisite();
301 $haystack = wp_parse_url( $requested_path, PHP_URL_QUERY );
302 if (
303 $is_multisite && is_string( $haystack )
304 && false !== strpos( $haystack, 'network_admin_hash' )
305 ) {
306 $logs_url = add_query_arg(
307 'redirect_to',
308 rawurlencode( $requested_path ),
309 $this->get_model()->get_new_login_url()
310 );
311 wp_safe_redirect( $logs_url );
312 die;
313 }
314 /**
315 * Block if it's:
316 * 1) no MU but there is an attempt to load the 'wp-signup.php' page,
317 * 2) from the list of forbidden slugs.
318 */
319 if (
320 ( ! $is_multisite && 'wp-signup.php' === $requested_path_without_slash )
321 || $this->service->is_on_login_page( $requested_path_without_slash )
322 ) {
323 // If they are here and the flow getting here, then just lock.
324 return $this->maybe_lock();
325 }
326 }
327
328 /**
329 * Save settings.
330 *
331 * @param Request $request The request object containing new settings data.
332 *
333 * @return Response
334 * @defender_route
335 */
336 public function save_settings( Request $request ): Response {
337 $data = $request->get_data_by_model( $this->model );
338 $this->model->import( $data );
339 if ( $this->model->validate() ) {
340 $this->model->save();
341 Config_Hub_Helper::set_clear_active_flag();
342
343 return new Response(
344 true,
345 array_merge(
346 array(
347 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
348 'auto_close' => true,
349 ),
350 $this->data_frontend()
351 )
352 );
353 }
354 $data_frontend = $this->data_frontend();
355 $result = array( 'message' => $this->model->get_formatted_errors() );
356 // Don't hide the error notice if the module is not activated.
357 if ( ! $data_frontend['is_active'] ) {
358 $result['auto_close'] = false;
359 }
360
361 return new Response(
362 false,
363 // Merge stored data to avoid errors.
364 array_merge( $result, $data_frontend )
365 );
366 }
367
368 /**
369 * Filter every admin/login URL to return the masked one.
370 *
371 * @param string $site_url The complete URL.
372 *
373 * @return string
374 */
375 public function filter_site_url( string $site_url ) {
376 return $this->alter_url( $site_url, 'site_url' );
377 }
378
379 /**
380 * Filters the WordPress redirect URL to return the masked one.
381 *
382 * @param string $location The complete URL.
383 *
384 * @return string The masked URL.
385 */
386 public function filter_wp_redirect( string $location ) {
387 return $this->alter_url( $location, 'wp_safe_redirect' );
388 }
389
390 /**
391 * Alters the URL based on the source and whether the user is logged in or not.
392 *
393 * @param string $current_url The current URL.
394 * @param string $source The source of the URL.
395 *
396 * @return string The altered URL.
397 */
398 public function alter_url( string $current_url, string $source ): string {
399 if ( is_user_logged_in() && false === stripos( $current_url, 'wp-login.php' ) ) {
400 // Do nothing.
401 return $current_url;
402 }
403
404 if ( 'wp_safe_redirect' === $source && ! is_user_logged_in() ) {
405 $parsed_url = wp_parse_url( $current_url );
406
407 $parsed_query = array();
408 if ( isset( $parsed_url['query'] ) ) {
409 wp_parse_str( $parsed_url['query'], $parsed_query );
410 }
411
412 if (
413 isset( $parsed_url['path'] ) && 'wp-login.php' === trim( $parsed_url['path'], '/' ) &&
414 isset( $parsed_query['checkemail'] ) && 'registered' === $parsed_query['checkemail']
415 ) {
416 return $this->model->get_mask_url() . $this->get_permalink_separator() . build_query( $parsed_query );
417 }
418 }
419
420 if (
421 'wp_safe_redirect' === $source &&
422 ! is_user_logged_in() &&
423 ! wd_di()->get( Blacklist_Lockout::class )->is_blc_ip_whitelisted()
424 ) {
425 return $this->protect_unauthorized_login_redirect( $current_url );
426 }
427
428 if ( false !== stripos( $current_url, 'wp-login.php' ) ) {
429 // This is URL go to old wp-login.php.
430 $query = wp_parse_url( $current_url, PHP_URL_QUERY );
431 $query = $query ?? '';
432 parse_str( $query, $params );
433
434 if ( isset( $params['login'] ) ) {
435 $params['login'] = rawurlencode( $params['login'] );
436 }
437
438 return add_query_arg( $params, $this->get_model()->get_new_login_url( $this->get_site_url() ) );
439 } else {
440 // This case when admin maps a domain into subsite, we need to update the new domain/masked-login into the list.
441 if ( ! function_exists( 'get_current_screen' ) ) {
442 require_once ABSPATH . 'wp-admin/includes/screen.php';
443 }
444 $screen = get_current_screen();
445
446 if ( ! is_object( $screen ) ) {
447 return $current_url;
448 }
449 if ( 'sites-network' === $screen->id ) {
450 // Case URLs inside sites list, need to check those with custom domain cause when it's redirect, it will require re-login.
451 $requested_path = $this->service->get_request_path( $current_url );
452 if ( '/wp-admin' === $requested_path ) {
453 $current_domain = defender_get_data_from_request( 'HTTP_HOST', 's' );
454 $sub_domain = wp_parse_url( $current_url, PHP_URL_HOST );
455 if ( is_string( $sub_domain ) && '' !== trim( $sub_domain ) && false === stripos( $sub_domain, $current_domain ) ) {
456 return $this->get_model()->get_new_login_url( $sub_domain );
457 }
458 }
459 }
460 /**
461 * Todo:
462 * add other condition ('my-sites' === $screen->id),
463 * create OTP key and link with the 'otp' arg inside
464 */
465 }
466
467 return $current_url;
468 }
469
470 /**
471 * Show the wp die screen for lockout, or redirect to defined URL.
472 *
473 * @return void
474 */
475 public function maybe_lock(): void {
476 if ( 'custom_url' === $this->get_model()->redirect_traffic && strlen( $this->get_model()->redirect_traffic_url ) ) {
477 if ( 'url' === $this->get_model()->is_url_or_slug() ) {
478 $redirect_url = wp_sanitize_redirect( $this->get_model()->redirect_traffic_url );
479 $lp = wp_parse_url( $redirect_url );
480
481 // Give up if malformed URL.
482 if ( false === $lp ) {
483 $this->show_forbidden_screen();
484 }
485 // If the URL is without scheme, e.g. example.com, then add 'http' protocol at the beginning of the URL.
486 if ( ! isset( $lp['scheme'] ) && isset( $lp['path'] ) ) {
487 $redirect_url = 'http://' . untrailingslashit( $redirect_url );
488 }
489 $parsed_url = wp_parse_url( $redirect_url, PHP_URL_HOST );
490 if ( is_string( $parsed_url ) ) {
491 /**
492 * Filters the list of allowed hosts to redirect to.
493 *
494 * @param string[] $hosts An array of allowed host names.
495 *
496 * @return string[] An array of allowed host names.
497 */
498 add_filter(
499 'allowed_redirect_hosts',
500 function ( array $hosts ) use ( $parsed_url ) {
501 $hosts[] = $parsed_url;
502
503 return $hosts;
504 },
505 );
506 }
507
508 wp_safe_redirect( $redirect_url );
509 } else {
510 wp_safe_redirect( home_url( $this->get_model()->redirect_traffic_url ) );
511 }
512 die;
513 }
514
515 if ( 'wp_page' === $this->get_model()->redirect_traffic ) {
516 $id = $this->get_model()->redirect_traffic_page_id;
517 $post = get_post( $id );
518 if ( is_object( $post ) ) {
519 wp_safe_redirect( get_permalink( $post ) );
520 exit;
521 }
522 }
523
524 // Handle user profile email change request.
525 $this->handle_email_change_request();
526
527 $this->show_forbidden_screen();
528 }
529
530 /**
531 * Show the forbidden screen.
532 */
533 public function show_forbidden_screen(): void {
534 wp_die(
535 esc_html__( 'This feature is forbidden temporarily for security reason. Try login again.', 'defender-security' ),
536 esc_html__( 'Forbidden', 'defender-security' ),
537 array(
538 'response' => 403,
539 )
540 );
541 }
542
543 /**
544 * Safe way to get cached model.
545 *
546 * @return Model_Mask_Login
547 */
548 private function get_model() {
549 if ( is_object( $this->model ) ) {
550 return $this->model;
551 }
552
553 return new Model_Mask_Login();
554 }
555
556 /**
557 * Provide data to the frontend via localized script.
558 *
559 * @param array $data Data collection is ready to passed.
560 *
561 * @return array Modified data array with added this controller data.
562 */
563 public function script_data( array $data ): array {
564 $data['mask_login'] = $this->data_frontend();
565
566 return $data;
567 }
568
569 /**
570 * Redirects the user to the admin URL if the given URL is the home URL.
571 *
572 * @param string $url The URL to be redirected.
573 * @param string $raw_url The raw URL.
574 * @param object $user The user object.
575 *
576 * @return string The filtered URL after applying the 'defender_redirect_login' filter.
577 */
578 public function redirect_login( $url, $raw_url, $user ) {
579 if ( home_url() === $url ) {
580 $url = admin_url();
581 }
582
583 return apply_filters( 'defender_redirect_login', $url, $raw_url, $user );
584 }
585
586 /**
587 * Retrieves the site URL based on the provided parameters.
588 * This function retrieves the site URL for the current or specified blog. If the blog ID is empty or not in a
589 * multisite environment, it retrieves the site URL from the 'siteurl' option. Otherwise, it switches to the
590 * specified blog, retrieves the site URL, and then restores the current blog.
591 *
592 * @param int|null $blog_id The blog ID in a multisite environment. Default is null.
593 * @param string $path Additional path to append to the site URL. Default is an empty string.
594 * @param string|null $scheme The scheme to use. Default is null.
595 *
596 * @return string The site URL with the specified parameters.
597 */
598 private function get_site_url( $blog_id = null, $path = '', $scheme = null ) {
599 if ( ! is_int( $blog_id ) || 0 === $blog_id || ! is_multisite() ) {
600 $url = get_option( 'siteurl' );
601 } else {
602 switch_to_blog( $blog_id );
603 $url = get_option( 'siteurl' );
604 restore_current_blog();
605 }
606
607 $url = set_url_scheme( $url, $scheme );
608
609 if ( $path && is_string( $path ) ) {
610 $url .= '/' . ltrim( $path, '/' );
611 }
612
613 /**
614 * Filters the list of plugins for which 'site_url' filter should be skipped.
615 *
616 * @param array $plugins A list of plugin file paths relative to the plugin's directory.
617 *
618 * @since 4.1.0
619 */
620 $plugins = apply_filters(
621 'wd_mask_login_skip_site_url_filter',
622 array( 'wp-ultimo/wp-ultimo.php' )
623 );
624 $skip_site_url_filter = false;
625 if ( is_array( $plugins ) ) {
626 foreach ( $plugins as $plugin ) {
627 if ( is_plugin_active( $plugin ) || is_plugin_active_for_network( $plugin ) ) {
628 $skip_site_url_filter = true;
629 break;
630 }
631 }
632 }
633
634 if ( $skip_site_url_filter ) {
635 return apply_filters( 'site_url', $url, $path, $scheme, $blog_id );
636 } else {
637 return $url;
638 }
639 }
640
641 /**
642 * Removes settings for all submodules.
643 */
644 public function remove_settings() {
645 }
646
647 /**
648 * Delete all the data & the cache.
649 */
650 public function remove_data() {
651 }
652
653 /**
654 * Converts the current object state to an array.
655 *
656 * @return array The array representation of the object.
657 */
658 public function to_array(): array {
659 $model = new Model_Mask_Login();
660 [ $routes, $nonces ] = Route::export_routes( 'mask_login' );
661
662 return array(
663 'enabled' => $model->enabled,
664 'useable' => $model->is_active(),
665 'login_url' => $model->get_new_login_url(),
666 'endpoints' => $routes,
667 'nonces' => $nonces,
668 );
669 }
670
671 /**
672 * Provides data for the dashboard widget.
673 *
674 * @return array An array of dashboard widget data.
675 */
676 public function dashboard_widget(): array {
677 $model = new Model_Mask_Login();
678
679 return array(
680 'model' => $model->export(),
681 'is_active' => $model->is_active(),
682 'is_mask_url_page_post_exists' => $model->is_mask_url_page_post_exists(),
683 );
684 }
685
686 /**
687 * Provides data for the frontend.
688 *
689 * @return array An array of data for the frontend.
690 */
691 public function data_frontend(): array {
692 // Don't use cache because wrong url is displayed for forbidden slugs.
693 $model = new Model_Mask_Login();
694
695 $data = array_merge(
696 array(
697 'model' => $model->export(),
698 'is_active' => $model->is_active(),
699 'new_login_url' => $model->get_new_login_url(),
700 'notices' => $this->compatibility_notices,
701 'is_mask_url_empty' => $model->is_mask_url_empty(),
702 'is_mask_url_page_post_exists' => $model->is_mask_url_page_post_exists(),
703 ),
704 $this->dump_routes_and_nonces()
705 );
706
707 if ( isset( $data['model']['redirect_traffic_page_id'] ) ) {
708 $id = $data['model']['redirect_traffic_page_id'];
709
710 $data['redirect_traffic_page_title'] = $id > 0 ? get_the_title( $id ) : '';
711 $data['redirect_traffic_page_url'] = $id > 0 ? get_the_permalink( $id ) : '#';
712 }
713
714 return $data;
715 }
716
717 /**
718 * Imports data into the model.
719 *
720 * @param array $data Data to be imported into the model.
721 */
722 public function import_data( array $data ) {
723 $model = $this->get_model();
724
725 $model->import( $data );
726 if ( $model->validate() ) {
727 $model->save();
728 }
729 }
730
731 /**
732 * Updates the welcome email for a specific site case.
733 *
734 * @param string $welcome_email The original welcome email content.
735 * @param int $blog_id The ID of the site.
736 * @param int $user_id The ID of the user.
737 * @param string $password The user's password.
738 * @param string $title The title of the welcome email.
739 * @param array $meta Additional metadata for the welcome email.
740 *
741 * @return string The updated welcome email content.
742 */
743 public function update_welcome_email_prosite_case(
744 string $welcome_email,
745 int $blog_id,
746 int $user_id,
747 string $password,
748 string $title,
749 array $meta
750 ) {
751 $url = get_blogaddress_by_id( $blog_id );
752 $welcome_email = str_replace(
753 $url . 'wp-login.php',
754 $this->get_model()->get_new_login_url( rtrim( $url, '/' ) ),
755 $welcome_email
756 );
757
758 return $welcome_email;
759 }
760
761 /**
762 * Updates the report logs link by adding a 'redirect_to' query parameter to the new login URL.
763 *
764 * @param string $logs_url The original logs URL.
765 * @param string $email The email address.
766 *
767 * @return string The updated logs URL with the 'redirect_to' query parameter.
768 */
769 public function update_report_logs_link( string $logs_url, string $email ): string {
770 return add_query_arg( 'redirect_to', $logs_url, $this->get_model()->get_new_login_url() );
771 }
772
773 /**
774 * Replaces the password reset link in the given message with a new login URL that includes a token.
775 *
776 * @param string $message The original message containing the password reset link.
777 * @param string $key The key used for the password reset link.
778 * @param string $user_login The username of the user.
779 * @param WP_User $user_data The user data.
780 *
781 * @return string The updated message with the new login URL.
782 * @since 2.5.5
783 */
784 public function flywheel_change_password_message(
785 string $message,
786 string $key,
787 string $user_login,
788 WP_User $user_data
789 ) {
790 $message = str_replace(
791 network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user_login ), 'login' ),
792 $this->get_model()->get_new_login_url( $this->get_site_url() )
793 . "?action=rp&key=$key&login=" . rawurlencode( $user_login ) . '&wd-ml-token=' . rawurlencode( $user_login ),
794 $message
795 );
796
797 return $message;
798 }
799
800 /**
801 * Change redirect param of the link 'Lost your password?'.
802 *
803 * @return string
804 */
805 public function change_lostpassword_redirect() {
806 return $this->get_model()->get_new_login_url( $this->get_site_url() ) . $this->get_permalink_separator() . 'checkemail=confirm';
807 }
808
809 /**
810 * Handle user profile email change request.
811 *
812 * @return void
813 */
814 private function handle_email_change_request() {
815 // If it is not for admin request.
816 if ( ! is_admin() ) {
817 return;
818 }
819
820 // If `IS_PROFILE_PAGE` constant is defined.
821 if ( ! defined( 'IS_PROFILE_PAGE' ) ) {
822 return;
823 }
824
825 // If request is not for profile page.
826 if ( ! IS_PROFILE_PAGE ) {
827 return;
828 }
829
830 // If query data is not set.
831 $hash = defender_get_data_from_request( 'newuseremail', 'g' );
832 if ( ! isset( $hash ) ) {
833 return;
834 }
835
836 global $wpdb;
837 $like = '%' . $wpdb->esc_like( $hash ) . '%';
838 $meta_key = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
839 $wpdb->prepare( "SELECT meta_key FROM {$wpdb->usermeta} WHERE meta_value LIKE %s", $like )
840 );
841
842 // Hash not found.
843 if ( '_new_email' !== $meta_key ) {
844 return;
845 }
846
847 // Everything good, now redirect user to login page.
848 $current_url = add_query_arg( defender_get_data_from_request( null, 'g' ), admin_url( 'profile.php' ) );
849 $redirect_url = esc_url( wp_login_url( $current_url ) );
850 wp_safe_redirect( $redirect_url );
851 die();
852 }
853
854 /**
855 * Exports strings.
856 *
857 * @return array An array of strings.
858 */
859 public function export_strings(): array {
860 return array(
861 $this->get_model()->is_active() ? esc_html__( 'Active', 'defender-security' ) : esc_html__( 'Inactive', 'defender-security' ),
862 );
863 }
864
865 /**
866 * Generates configuration strings based on the provided configuration and
867 * whether the product is a pro version.
868 *
869 * @param array $config Configuration data.
870 * @param bool $is_pro Indicates if the product is a pro version.
871 *
872 * @return array Returns an array of configuration strings.
873 */
874 public function config_strings( array $config, bool $is_pro ): array {
875 return array(
876 $config['enabled'] ? esc_html__( 'Active', 'defender-security' ) : esc_html__( 'Inactive', 'defender-security' ),
877 );
878 }
879
880 /**
881 * Support for the password reset page on various hosting.
882 *
883 * @return void
884 */
885 public function handle_password_reset(): void {
886 // Get the email link.
887 $action = defender_get_data_from_request( 'action', 'g' );
888 $key = wp_unslash( defender_get_data_from_request( 'key', 'r' ) );
889 $login = wp_unslash( defender_get_data_from_request( 'login', 'r' ) );
890 $wd_ml_token = defender_get_data_from_request( 'wd-ml-token', 'g' );
891 if (
892 isset( $action, $key, $login, $wd_ml_token )
893 && 'rp' === $action
894 && $login === $wd_ml_token
895 ) {
896
897 $user = check_password_reset_key( $key, $login );
898 if ( ! is_wp_error( $user ) ) {
899 $value = sprintf( '%s:%s', $login, $key );
900 set_site_transient( 'wd-rp-' . COOKIEHASH, $value, 2 * MINUTE_IN_SECONDS );
901 wp_safe_redirect( remove_query_arg( array( 'key', 'login', 'wd-ml-token' ) ) );
902 exit;
903 }
904 }
905 $value = get_site_transient( 'wd-rp-' . COOKIEHASH );
906 // Process the data and display the result.
907 if (
908 isset( $action )
909 && in_array( $action, array( 'rp', 'resetpass' ), true )
910 && isset( $value ) && 0 < strpos( $value, ':' )
911 ) {
912 [ $login, $key ] = explode( ':', wp_unslash( $value ), 2 );
913 $user = check_password_reset_key( $key, $login );
914 if ( 'resetpass' === $action ) {
915 delete_site_transient( 'wd-rp-' . COOKIEHASH );
916 }
917 if ( ! is_wp_error( $user ) ) {
918 $this->render_partial(
919 'mask-login/reset',
920 array(
921 'user' => $user,
922 )
923 );
924 exit;
925 }
926 }
927 }
928
929 /**
930 * Check if a path is allowed without login masking.
931 *
932 * @param string $path Path to check.
933 *
934 * @return bool
935 * @since 2.6.4
936 */
937 private function is_allowed_path( string $path ): bool {
938 // Admin post requests to admin-post.php should be allowed.
939 $action = defender_get_data_from_request( 'action', 'r' );
940 $allowed = 'wp-admin/admin-post.php' === $path && is_string( $action ) && '' !== trim( $action );
941
942 /**
943 * Filter to allow whitelisting paths from login masking.
944 *
945 * @param bool $allowed Is current path allowed?.
946 * @param string $path Path to check.
947 *
948 * @since 2.6.4
949 */
950 return apply_filters( 'wd_mask_login_is_allowed_path', $allowed, $path );
951 }
952
953 /**
954 * An endpoint for fetching Post/Page.
955 *
956 * @param Request $request Request data.
957 *
958 * @return void
959 * @since 2.7.1
960 * @defender_route
961 */
962 public function get_posts( Request $request ): void {
963 $data = $request->get_data(
964 array(
965 'per_page' => array(
966 'type' => 'int',
967 'sanitize' => 'sanitize_text_field',
968 ),
969 'search' => array(
970 'type' => 'string',
971 'sanitize' => 'sanitize_text_field',
972 ),
973 )
974 );
975
976 $per_page = $data['per_page'] ?? 50;
977 $search = $data['search'] ?? '';
978
979 add_filter( 'posts_where', array( $this, 'posts_where_title' ), 10, 2 );
980 $post_query = new WP_Query(
981 array(
982 'post_type' => array( 'page', 'post' ),
983 'posts_per_page' => $per_page,
984 'search_by_post_title' => $search,
985 'post_status' => 'publish',
986 'orderby' => 'title',
987 'order' => 'ASC',
988 )
989 );
990 remove_filter( 'posts_where', array( $this, 'posts_where_title' ), 10 );
991
992 $posts_array = $post_query->posts;
993 $data = array();
994 foreach ( $posts_array as $post ) {
995 $data[] = array(
996 'id' => $post->ID,
997 'name' => $post->post_title,
998 'url' => get_the_permalink( $post->ID ),
999 );
1000 }
1001
1002 wp_send_json_success( $data );
1003 }
1004
1005 /**
1006 * Filter the WHERE clause of the query.
1007 *
1008 * @param string $where The WHERE clause of the query.
1009 * @param WP_Query $wp_query The query object.
1010 *
1011 * @return string $where
1012 * @since 2.7.1
1013 */
1014 public function posts_where_title( string $where, WP_Query $wp_query ) {
1015 global $wpdb;
1016
1017 $search_term = $wp_query->get( 'search_by_post_title' );
1018 if ( is_string( $search_term ) && '' !== trim( $search_term ) ) {
1019 $where .= ' AND ' . $wpdb->posts . '.post_title LIKE \'%' . esc_sql( $wpdb->esc_like( $search_term ) ) . '%\'';
1020 }
1021
1022 return $where;
1023 }
1024
1025 /**
1026 * Change the admin URL for sub sites.
1027 *
1028 * @param string $url The original URL.
1029 * @param string $path The path of the URL.
1030 * @param mixed $blog_id The ID of the blog.
1031 *
1032 * @return string The modified URL.
1033 */
1034 public function change_subsites_admin_url( string $url, string $path, $blog_id ) {
1035 if ( '' === trim( $path ) && is_int( $blog_id ) && 0 < $blog_id ) {
1036 $mask_url = trim( $this->model->mask_url );
1037
1038 if ( '' !== $mask_url && $this->check_if_domain_is_mapped( $url ) ) {
1039 $url = str_replace( 'wp-admin', $mask_url, untrailingslashit( $url ) );
1040 }
1041 }
1042
1043 return $url;
1044 }
1045
1046 /**
1047 * Check if domain is mapped.
1048 *
1049 * @param string $url The URL.
1050 *
1051 * @return bool
1052 */
1053 public function check_if_domain_is_mapped( string $url ): bool {
1054 $is_mapped = false;
1055
1056 if ( '' !== trim( $url ) ) {
1057 $url_arr = wp_parse_url( $url );
1058 $net_url_arr = wp_parse_url( network_site_url() );
1059
1060 if (
1061 isset( $url_arr['host'] ) && is_string( $url_arr['host'] ) && '' !== trim( $url_arr['host'] ) &&
1062 isset( $net_url_arr['host'] ) && is_string( $net_url_arr['host'] ) && '' !== trim( $net_url_arr['host'] ) &&
1063 $this->get_domain_from_host( $url_arr['host'] ) !== $this->get_domain_from_host( $net_url_arr['host'] )
1064 ) {
1065 $is_mapped = true;
1066 }
1067 }
1068
1069 return $is_mapped;
1070 }
1071
1072 /**
1073 * Extract domain from host.
1074 *
1075 * @param string $host The host.
1076 *
1077 * @return string
1078 */
1079 public function get_domain_from_host( string $host ): string {
1080 $host = strtolower( trim( $host ) );
1081
1082 $count = substr_count( $host, '.' );
1083 if ( 2 === $count ) {
1084 if ( strlen( explode( '.', $host )[1] ) > 3 ) {
1085 $host = explode( '.', $host, 2 )[1];
1086 }
1087 } elseif ( $count > 2 ) {
1088 $host = $this->get_domain_from_host( explode( '.', $host, 2 )[1] );
1089 }
1090
1091 return $host;
1092 }
1093
1094 /**
1095 * Update admin bar menu url to masked login url if domain is mapped.
1096 *
1097 * @param WP_Admin_Bar $admin_bar Admin bar object.
1098 *
1099 * @return void
1100 * @since 3.4.0
1101 */
1102 public function update_admin_bar_menu( WP_Admin_Bar $admin_bar ) {
1103 $mask_url = trim( $this->model->mask_url );
1104 if ( '' === $mask_url ) {
1105 return;
1106 }
1107
1108 $admin_bar_nodes = $admin_bar->get_nodes();
1109 $needle = '/wp-admin/';
1110 $length = strlen( $needle );
1111 foreach ( $admin_bar_nodes as $nodes ) {
1112 if ( substr( $nodes->href, - $length ) === $needle && $this->check_if_domain_is_mapped( $nodes->href ) ) {
1113 $href = str_replace( 'wp-admin', $mask_url, untrailingslashit( $nodes->href ) );
1114
1115 $admin_bar->add_menu(
1116 array(
1117 'id' => $nodes->id,
1118 'href' => $href,
1119 )
1120 );
1121 }
1122 }
1123 }
1124
1125 /**
1126 * Update my sites action url to masked login url if domain is mapped.
1127 *
1128 * @param string $actions The current action links.
1129 * @param object $user_blog The user blog object.
1130 *
1131 * @return string
1132 * @since 3.4.0
1133 */
1134 public function update_myblogs_blog_actions( string $actions, object $user_blog ) {
1135 $mask_url = trim( $this->model->mask_url );
1136
1137 if ( '' === $mask_url ) {
1138 return $actions;
1139 }
1140
1141 $admin_url = get_admin_url( $user_blog->userblog_id );
1142
1143 if ( $this->check_if_domain_is_mapped( $admin_url ) ) {
1144 $updated_admin_url = str_replace( 'wp-admin', $mask_url, untrailingslashit( $admin_url ) );
1145 $actions = str_replace( $admin_url, $updated_admin_url, $actions );
1146 }
1147
1148 return $actions;
1149 }
1150
1151 /**
1152 * Set locale on Mask Login page.
1153 *
1154 * @return void
1155 * @since 3.12.0
1156 */
1157 public function set_locale(): void {
1158 $this->service->set_locale();
1159 }
1160
1161 /**
1162 * Enable/disable module.
1163 *
1164 * @param Request $request Request object.
1165 *
1166 * @return Response
1167 * @defender_route
1168 * @since 3.12.0
1169 */
1170 public function toggle_module( Request $request ): Response {
1171 $data = $request->get_data(
1172 array(
1173 'enabled' => array(
1174 'type' => 'boolean',
1175 ),
1176 )
1177 );
1178
1179 $this->model->enabled = $data['enabled'];
1180 $this->model->save();
1181
1182 Config_Hub_Helper::set_clear_active_flag();
1183
1184 if ( ! $this->model->enabled || ! $this->model->is_mask_url_page_post_exists() ) {
1185 return new Response(
1186 true,
1187 array_merge(
1188 array(
1189 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
1190 'auto_close' => true,
1191 ),
1192 $this->data_frontend()
1193 )
1194 );
1195 }
1196
1197 return new Response(
1198 false,
1199 array(
1200 'error' => esc_html__(
1201 'A page already exists at this URL. Please enter a unique URL for your login area.',
1202 'defender-security'
1203 ),
1204 )
1205 );
1206 }
1207
1208 /**
1209 * Get permalink separator.
1210 *
1211 * @return string
1212 */
1213 public function get_permalink_separator(): string {
1214 return $this->model->is_permalink_structure_empty() ? '&' : '?';
1215 }
1216 }
1217