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

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