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

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