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

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