PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.5.1
Defender Security – Malware Scanner, Login Security & Firewall v5.5.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.5.1, at src/controller/class-mask-login.php

1,216 lines 34.4 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 && ! empty( 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['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 ( ! empty( $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 ( empty( $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 $allowed = 'wp-admin/admin-post.php' === $path && ! empty( defender_get_data_from_request( 'action', 'r' ) );
940
941 /**
942 * Filter to allow whitelisting paths from login masking.
943 *
944 * @param bool $allowed Is current path allowed?.
945 * @param string $path Path to check.
946 *
947 * @since 2.6.4
948 */
949 return apply_filters( 'wd_mask_login_is_allowed_path', $allowed, $path );
950 }
951
952 /**
953 * An endpoint for fetching Post/Page.
954 *
955 * @param Request $request Request data.
956 *
957 * @return void
958 * @since 2.7.1
959 * @defender_route
960 */
961 public function get_posts( Request $request ): void {
962 $data = $request->get_data(
963 array(
964 'per_page' => array(
965 'type' => 'int',
966 'sanitize' => 'sanitize_text_field',
967 ),
968 'search' => array(
969 'type' => 'string',
970 'sanitize' => 'sanitize_text_field',
971 ),
972 )
973 );
974
975 $per_page = $data['per_page'] ?? 50;
976 $search = $data['search'] ?? '';
977
978 add_filter( 'posts_where', array( $this, 'posts_where_title' ), 10, 2 );
979 $post_query = new WP_Query(
980 array(
981 'post_type' => array( 'page', 'post' ),
982 'posts_per_page' => $per_page,
983 'search_by_post_title' => $search,
984 'post_status' => 'publish',
985 'orderby' => 'title',
986 'order' => 'ASC',
987 )
988 );
989 remove_filter( 'posts_where', array( $this, 'posts_where_title' ), 10 );
990
991 $posts_array = $post_query->posts;
992 $data = array();
993 foreach ( $posts_array as $post ) {
994 $data[] = array(
995 'id' => $post->ID,
996 'name' => $post->post_title,
997 'url' => get_the_permalink( $post->ID ),
998 );
999 }
1000
1001 wp_send_json_success( $data );
1002 }
1003
1004 /**
1005 * Filter the WHERE clause of the query.
1006 *
1007 * @param string $where The WHERE clause of the query.
1008 * @param WP_Query $wp_query The query object.
1009 *
1010 * @return string $where
1011 * @since 2.7.1
1012 */
1013 public function posts_where_title( string $where, WP_Query $wp_query ) {
1014 global $wpdb;
1015
1016 $search_term = $wp_query->get( 'search_by_post_title' );
1017 if ( ! empty( $search_term ) ) {
1018 $where .= ' AND ' . $wpdb->posts . '.post_title LIKE \'%' . esc_sql( $wpdb->esc_like( $search_term ) ) . '%\'';
1019 }
1020
1021 return $where;
1022 }
1023
1024 /**
1025 * Change the admin URL for sub sites.
1026 *
1027 * @param string $url The original URL.
1028 * @param string $path The path of the URL.
1029 * @param mixed $blog_id The ID of the blog.
1030 *
1031 * @return string The modified URL.
1032 */
1033 public function change_subsites_admin_url( string $url, string $path, $blog_id ) {
1034 if ( empty( $path ) && ! empty( $blog_id ) ) {
1035 $mask_url = trim( $this->model->mask_url );
1036
1037 if ( ! empty( $mask_url ) && $this->check_if_domain_is_mapped( $url ) ) {
1038 $url = str_replace( 'wp-admin', $mask_url, untrailingslashit( $url ) );
1039 }
1040 }
1041
1042 return $url;
1043 }
1044
1045 /**
1046 * Check if domain is mapped.
1047 *
1048 * @param string $url The URL.
1049 *
1050 * @return bool
1051 */
1052 public function check_if_domain_is_mapped( string $url ): bool {
1053 $is_mapped = false;
1054
1055 if ( ! empty( $url ) ) {
1056 $url_arr = wp_parse_url( $url );
1057 $net_url_arr = wp_parse_url( network_site_url() );
1058
1059 if (
1060 ! empty( $url_arr['host'] ) &&
1061 ! empty( $net_url_arr['host'] ) &&
1062 $this->get_domain_from_host( $url_arr['host'] ) !== $this->get_domain_from_host( $net_url_arr['host'] )
1063 ) {
1064 $is_mapped = true;
1065 }
1066 }
1067
1068 return $is_mapped;
1069 }
1070
1071 /**
1072 * Extract domain from host.
1073 *
1074 * @param string $host The host.
1075 *
1076 * @return string
1077 */
1078 public function get_domain_from_host( string $host ): string {
1079 $host = strtolower( trim( $host ) );
1080
1081 $count = substr_count( $host, '.' );
1082 if ( 2 === $count ) {
1083 if ( strlen( explode( '.', $host )[1] ) > 3 ) {
1084 $host = explode( '.', $host, 2 )[1];
1085 }
1086 } elseif ( $count > 2 ) {
1087 $host = $this->get_domain_from_host( explode( '.', $host, 2 )[1] );
1088 }
1089
1090 return $host;
1091 }
1092
1093 /**
1094 * Update admin bar menu url to masked login url if domain is mapped.
1095 *
1096 * @param WP_Admin_Bar $admin_bar Admin bar object.
1097 *
1098 * @return void
1099 * @since 3.4.0
1100 */
1101 public function update_admin_bar_menu( WP_Admin_Bar $admin_bar ) {
1102 $mask_url = trim( $this->model->mask_url );
1103 if ( empty( $mask_url ) ) {
1104 return;
1105 }
1106
1107 $admin_bar_nodes = $admin_bar->get_nodes();
1108 $needle = '/wp-admin/';
1109 $length = strlen( $needle );
1110 foreach ( $admin_bar_nodes as $nodes ) {
1111 if ( substr( $nodes->href, - $length ) === $needle && $this->check_if_domain_is_mapped( $nodes->href ) ) {
1112 $href = str_replace( 'wp-admin', $mask_url, untrailingslashit( $nodes->href ) );
1113
1114 $admin_bar->add_menu(
1115 array(
1116 'id' => $nodes->id,
1117 'href' => $href,
1118 )
1119 );
1120 }
1121 }
1122 }
1123
1124 /**
1125 * Update my sites action url to masked login url if domain is mapped.
1126 *
1127 * @param string $actions The current action links.
1128 * @param object $user_blog The user blog object.
1129 *
1130 * @return string
1131 * @since 3.4.0
1132 */
1133 public function update_myblogs_blog_actions( string $actions, object $user_blog ) {
1134 $mask_url = trim( $this->model->mask_url );
1135
1136 if ( empty( $mask_url ) ) {
1137 return $actions;
1138 }
1139
1140 $admin_url = get_admin_url( $user_blog->userblog_id );
1141
1142 if ( $this->check_if_domain_is_mapped( $admin_url ) ) {
1143 $updated_admin_url = str_replace( 'wp-admin', $mask_url, untrailingslashit( $admin_url ) );
1144 $actions = str_replace( $admin_url, $updated_admin_url, $actions );
1145 }
1146
1147 return $actions;
1148 }
1149
1150 /**
1151 * Set locale on Mask Login page.
1152 *
1153 * @return void
1154 * @since 3.12.0
1155 */
1156 public function set_locale(): void {
1157 $this->service->set_locale();
1158 }
1159
1160 /**
1161 * Enable/disable module.
1162 *
1163 * @param Request $request Request object.
1164 *
1165 * @return Response
1166 * @defender_route
1167 * @since 3.12.0
1168 */
1169 public function toggle_module( Request $request ): Response {
1170 $data = $request->get_data(
1171 array(
1172 'enabled' => array(
1173 'type' => 'boolean',
1174 ),
1175 )
1176 );
1177
1178 $this->model->enabled = $data['enabled'];
1179 $this->model->save();
1180
1181 Config_Hub_Helper::set_clear_active_flag();
1182
1183 if ( ! $this->model->enabled || ! $this->model->is_mask_url_page_post_exists() ) {
1184 return new Response(
1185 true,
1186 array_merge(
1187 array(
1188 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
1189 'auto_close' => true,
1190 ),
1191 $this->data_frontend()
1192 )
1193 );
1194 }
1195
1196 return new Response(
1197 false,
1198 array(
1199 'error' => esc_html__(
1200 'A page already exists at this URL. Please enter a unique URL for your login area.',
1201 'defender-security'
1202 ),
1203 )
1204 );
1205 }
1206
1207 /**
1208 * Get permalink separator.
1209 *
1210 * @return string
1211 */
1212 public function get_permalink_separator(): string {
1213 return $this->model->is_permalink_structure_empty() ? '&' : '?';
1214 }
1215 }
1216