PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-connection / src / sso / class-sso.php

class-sso.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-connection/src/sso/class-sso.php

1,728 lines 55.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SSO feature. Entry point.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8 namespace Automattic\Jetpack\Connection;
9
10 use Automattic\Jetpack\Assets;
11 use Automattic\Jetpack\Connection\SSO\Force_2FA;
12 use Automattic\Jetpack\Connection\SSO\Helpers;
13 use Automattic\Jetpack\Connection\SSO\Notices;
14 use Automattic\Jetpack\Connection\SSO\User_Admin;
15 use Automattic\Jetpack\Connection\Webhooks\Authorize_Redirect;
16 use Automattic\Jetpack\Constants;
17 use Automattic\Jetpack\Roles;
18 use Automattic\Jetpack\Status;
19 use Automattic\Jetpack\Status\Host;
20 use Automattic\Jetpack\Tracking;
21 use Jetpack_IXR_Client;
22 use WP_Error;
23 use WP_User;
24 use WP_User_Query;
25
26 /**
27 * SSO feature main class.
28 */
29 class SSO {
30 /**
31 * WordPress.com User information.
32 *
33 * @var false|object
34 */
35 private $user_data;
36
37 /**
38 * Automattic\Jetpack\Connection\SSO instance.
39 *
40 * @var \Automattic\Jetpack\Connection\SSO
41 */
42 public static $instance = null;
43
44 /**
45 * Stores the WP_User being authenticated via SSO so the
46 * attach_session_information callback can tag the session.
47 *
48 * @var WP_User|null
49 */
50 private static $sso_user_for_2fa = null;
51
52 /**
53 * Cookie name for the SSO broker authorization signal.
54 *
55 * Set when WP.com signals that a broker should be used for SSO. The cookie
56 * value is the SSO nonce, tying the signal to a specific authentication flow.
57 *
58 * @var string
59 */
60 const BROKER_COOKIE = 'jetpack_sso_broker';
61
62 /**
63 * Automattic\Jetpack\Connection\SSO constructor.
64 */
65 private function __construct() {
66
67 self::$instance = $this;
68
69 add_action( 'admin_init', array( $this, 'maybe_authorize_user_after_sso' ), 1 );
70 add_action( 'admin_init', array( $this, 'register_settings' ) );
71 add_action( 'login_init', array( $this, 'login_init' ) );
72 add_filter( 'jetpack_xmlrpc_methods', array( $this, 'xmlrpc_methods' ) );
73 add_action( 'init', array( $this, 'maybe_logout_user' ), 5 );
74 add_action( 'login_form_logout', array( $this, 'store_wpcom_profile_cookies_on_logout' ) );
75 add_action( 'jetpack_unlinked_user', array( Helpers::class, 'delete_connection_for_user' ) );
76
77 add_action( 'jetpack_site_before_disconnected', array( static::class, 'disconnect' ) );
78 add_action( 'wp_login', array( static::class, 'clear_cookies_after_login' ) );
79
80 // Adding this action so that on login_init, the action won't be sanitized out of the $action global.
81 add_action( 'login_form_jetpack-sso', '__return_true' );
82
83 add_filter( 'wp_login_errors', array( $this, 'sso_reminder_logout_wpcom' ) );
84
85 // Synchronize SSO options with WordPress.com.
86 add_filter( 'jetpack_sync_callable_whitelist', array( $this, 'sync_sso_callables' ), 10, 1 );
87
88 /**
89 * Filter to include Force 2FA feature.
90 *
91 * By default, `manage_options` users are forced when enable. The capability can be modified
92 * with the `jetpack_force_2fa_cap` filter.
93 *
94 * To enable the feature, add the following code:
95 * add_filter( 'jetpack_force_2fa', '__return_true' );
96 *
97 * @param bool $force_2fa Whether to force 2FA or not.
98 *
99 * @todo Provide a UI to enable/disable the feature.
100 *
101 * @since jetpack-12.7
102 * @module SSO
103 * @return bool
104 */
105 if (
106 ! class_exists( 'Automattic\Jetpack\Connection\SSO\Force_2FA', false )
107 && apply_filters( 'jetpack_force_2fa', false )
108 ) {
109 new Force_2FA();
110 }
111
112 /*
113 * Allow admins to invite new users to create a WordPress.com account
114 * as they are added to the site.
115 *
116 * This is a feature that is only available when the admin is connected to WordPress.com.
117 */
118 if (
119 ( new Manager() )->is_user_connected() &&
120 ! is_multisite() &&
121 /**
122 * Toggle the ability to invite new users to create a WordPress.com account.
123 *
124 * @module sso
125 *
126 * @since 2.7.2
127 *
128 * @param bool true Whether to allow admins to invite new users to create a WordPress.com account.
129 */
130 apply_filters( 'jetpack_sso_invite_new_users_wpcom', true )
131 ) {
132 new User_Admin();
133 }
134 }
135
136 /**
137 * Returns the single instance of the Automattic\Jetpack\Connection\SSO object
138 *
139 * @since jetpack-2.8
140 * @return \Automattic\Jetpack\Connection\SSO
141 */
142 public static function get_instance() {
143 if ( self::$instance !== null ) {
144 return self::$instance;
145 }
146
147 self::$instance = new SSO();
148 return self::$instance;
149 }
150
151 /**
152 * Add SSO callables to the sync whitelist.
153 *
154 * @since 2.8.1
155 *
156 * @param array $callables list of callables.
157 *
158 * @return array list of callables.
159 */
160 public function sync_sso_callables( $callables ) {
161 $sso_callables = array(
162 'sso_is_two_step_required' => array( Helpers::class, 'is_two_step_required' ),
163 'sso_should_hide_login_form' => array( Helpers::class, 'should_hide_login_form' ),
164 'sso_match_by_email' => array( Helpers::class, 'match_by_email' ),
165 'sso_new_user_override' => array( Helpers::class, 'new_user_override' ),
166 'sso_bypass_default_login_form' => array( Helpers::class, 'bypass_login_forward_wpcom' ),
167 );
168
169 return array_merge( $callables, $sso_callables );
170 }
171
172 /**
173 * Safety heads-up added to the logout messages when SSO is enabled.
174 * Some folks on a shared computer don't know that they need to log out of WordPress.com as well.
175 *
176 * @param WP_Error $errors WP_Error object.
177 */
178 public function sso_reminder_logout_wpcom( $errors ) {
179 if ( ( new Host() )->is_wpcom_platform() ) {
180 return $errors;
181 }
182
183 if ( ! empty( $errors->errors['loggedout'] ) ) {
184 $logout_message = wp_kses(
185 sprintf(
186 /* translators: %1$s is a link to the WordPress.com account settings page. */
187 __( 'If you are on a shared computer, remember to also <a href="%1$s">log out of WordPress.com</a>.', 'jetpack-connection' ),
188 'https://wordpress.com/me'
189 ),
190 array(
191 'a' => array(
192 'href' => array(),
193 ),
194 )
195 );
196 $errors->add( 'jetpack-sso-show-logout', $logout_message, 'message' );
197 }
198 return $errors;
199 }
200
201 /**
202 * If jetpack_force_logout == 1 in current user meta the user will be forced
203 * to logout and reauthenticate with the site.
204 **/
205 public function maybe_logout_user() {
206 global $current_user;
207
208 if ( 1 === (int) $current_user->jetpack_force_logout ) {
209 delete_user_meta( $current_user->ID, 'jetpack_force_logout' );
210 Helpers::delete_connection_for_user( $current_user->ID );
211 wp_logout();
212 wp_safe_redirect( wp_login_url() );
213 exit( 0 );
214 }
215 }
216
217 /**
218 * Adds additional methods the WordPress xmlrpc API for handling SSO specific features
219 *
220 * @param array $methods API methods.
221 * @return array
222 **/
223 public function xmlrpc_methods( $methods ) {
224 $methods['jetpack.userDisconnect'] = array( $this, 'xmlrpc_user_disconnect' );
225 return $methods;
226 }
227
228 /**
229 * Marks a user's profile for disconnect from WordPress.com and forces a logout
230 * the next time the user visits the site.
231 *
232 * @param int $user_id User to disconnect from the site.
233 **/
234 public function xmlrpc_user_disconnect( $user_id ) {
235 $user = self::get_user_by_wpcom_id( $user_id );
236
237 if ( $user instanceof WP_User ) {
238 $user = wp_set_current_user( $user->ID );
239 update_user_meta( $user->ID, 'jetpack_force_logout', '1' );
240 Helpers::delete_connection_for_user( $user->ID );
241 return true;
242 }
243 return false;
244 }
245
246 /**
247 * Enqueues scripts and styles necessary for SSO login.
248 */
249 public function login_enqueue_scripts() {
250 global $action;
251
252 if ( ! Helpers::display_sso_form_for_action( $action ) ) {
253 return;
254 }
255
256 Assets::register_script(
257 'jetpack-sso-login',
258 '../../dist/jetpack-sso-login.js',
259 __FILE__,
260 array(
261 'enqueue' => true,
262 'version' => Package_Version::PACKAGE_VERSION,
263 )
264 );
265 }
266
267 /**
268 * Adds Jetpack SSO classes to login body
269 *
270 * @param array $classes Array of classes to add to body tag.
271 * @return array Array of classes to add to body tag.
272 */
273 public function login_body_class( $classes ) {
274 global $action;
275
276 if ( ! Helpers::display_sso_form_for_action( $action ) ) {
277 return $classes;
278 }
279
280 // Always add the jetpack-sso class so that we can add SSO specific styling even when the SSO form isn't being displayed.
281 $classes[] = 'jetpack-sso';
282
283 if ( ! ( new Status() )->in_safe_mode() ) {
284 /**
285 * Should we show the SSO login form?
286 *
287 * $_GET['jetpack-sso-default-form'] is used to provide a fallback in case JavaScript is not enabled.
288 *
289 * The default_to_sso_login() method allows us to dynamically decide whether we show the SSO login form or not.
290 * The SSO module uses the method to display the default login form if we cannot find a user to log in via SSO.
291 * But, the method could be filtered by a site admin to always show the default login form if that is preferred.
292 */
293 $default_form_preference = isset( $_GET['jetpack-sso-show-default-form'] ) ? sanitize_text_field( wp_unslash( $_GET['jetpack-sso-show-default-form'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
294 $show_sso_form = empty( $default_form_preference ) && Helpers::show_sso_login();
295
296 if ( 'entered_recovery_mode' === $action ) {
297 if ( '0' === $default_form_preference ) {
298 // Explicit user opt-in via the no-JS toggle; honor it regardless of show_sso_login() so the toggle always works.
299 $show_sso_form = true;
300 } elseif ( null === $default_form_preference && ! Helpers::should_hide_login_form() ) {
301 // Recovery is the break-glass fallback, so default to the wp-admin password form. Skip when that form is hidden, otherwise no login path would work.
302 $show_sso_form = false;
303 }
304 }
305
306 if ( $show_sso_form ) {
307 $classes[] = 'jetpack-sso-form-display';
308 }
309 }
310
311 return $classes;
312 }
313
314 /**
315 * Print the SSO styles for the login screen.
316 *
317 * @deprecated 8.12.0 Use enqueue_login_styles().
318 */
319 public function print_inline_admin_css() {
320 _deprecated_function( __METHOD__, 'connection-8.12.0', __CLASS__ . '::enqueue_login_styles' );
321 $this->enqueue_login_styles();
322 }
323
324 /**
325 * Enqueue the SSO styles for the login screen.
326 */
327 public function enqueue_login_styles() {
328 $handle = 'jetpack-sso-login-styles';
329
330 // No src: the handle only carries the inline CSS below. Core enqueues `login` before `login_enqueue_scripts` fires,
331 // so these rules already print after the core login stylesheet, which sets `.message` margins at the same
332 // specificity. No dependency on `login`: plugins that replace the login screen deregister that handle, and a
333 // missing dependency would drop this one from the queue.
334 wp_register_style( $handle, false, array(), Package_Version::PACKAGE_VERSION );
335 wp_enqueue_style( $handle );
336
337 $css = <<<'CSS'
338 .jetpack-sso .message {
339 margin-top: 20px;
340 }
341
342 .jetpack-sso #login .message:first-child,
343 .jetpack-sso #login h1 + .message {
344 margin-top: 0;
345 }
346 CSS;
347
348 wp_add_inline_style( $handle, $css );
349 }
350
351 /**
352 * Adds settings fields to Settings > General > Secure Sign On that allows users to
353 * turn off the login form on wp-login.php
354 *
355 * @since jetpack-2.7
356 **/
357 public function register_settings() {
358
359 add_settings_section(
360 'jetpack_sso_settings',
361 __( 'Secure Sign On', 'jetpack-connection' ),
362 '__return_false',
363 'jetpack-sso'
364 );
365
366 /*
367 * Settings > General > Secure Sign On
368 * Require two step authentication
369 */
370 register_setting(
371 'jetpack-sso',
372 'jetpack_sso_require_two_step',
373 array( $this, 'validate_jetpack_sso_require_two_step' )
374 );
375
376 add_settings_field(
377 'jetpack_sso_require_two_step',
378 '', // Output done in render $callback: __( 'Require Two-Step Authentication' , 'jetpack-connection' ).
379 array( $this, 'render_require_two_step' ),
380 'jetpack-sso',
381 'jetpack_sso_settings'
382 );
383
384 /*
385 * Settings > General > Secure Sign On
386 */
387 register_setting(
388 'jetpack-sso',
389 'jetpack_sso_match_by_email',
390 array( $this, 'validate_jetpack_sso_match_by_email' )
391 );
392
393 add_settings_field(
394 'jetpack_sso_match_by_email',
395 '', // Output done in render $callback: __( 'Match by Email' , 'jetpack-connection' ).
396 array( $this, 'render_match_by_email' ),
397 'jetpack-sso',
398 'jetpack_sso_settings'
399 );
400 }
401
402 /**
403 * Builds the display for the checkbox allowing user to require two step
404 * auth be enabled on WordPress.com accounts before login. Displays in Settings > General
405 *
406 * @since jetpack-2.7
407 **/
408 public function render_require_two_step() {
409 ?>
410 <label>
411 <input
412 type="checkbox"
413 name="jetpack_sso_require_two_step"
414 <?php checked( Helpers::is_two_step_required() ); ?>
415 <?php disabled( Helpers::is_require_two_step_checkbox_disabled() ); ?>
416 >
417 <?php esc_html_e( 'Require Two-Step Authentication', 'jetpack-connection' ); ?>
418 </label>
419 <?php
420 }
421
422 /**
423 * Validate the require two step checkbox in Settings > General.
424 *
425 * @param bool $input The jetpack_sso_require_two_step option setting.
426 *
427 * @since jetpack-2.7
428 * @return int
429 **/
430 public function validate_jetpack_sso_require_two_step( $input ) {
431 return ( ! empty( $input ) ) ? 1 : 0;
432 }
433
434 /**
435 * Builds the display for the checkbox allowing the user to allow matching logins by email
436 * Displays in Settings > General
437 *
438 * @since jetpack-2.9
439 **/
440 public function render_match_by_email() {
441 ?>
442 <label>
443 <input
444 type="checkbox"
445 name="jetpack_sso_match_by_email"
446 <?php checked( Helpers::match_by_email() ); ?>
447 <?php disabled( Helpers::is_match_by_email_checkbox_disabled() ); ?>
448 >
449 <?php esc_html_e( 'Match by Email', 'jetpack-connection' ); ?>
450 </label>
451 <?php
452 }
453
454 /**
455 * Validate the match by email check in Settings > General.
456 *
457 * @param bool $input The jetpack_sso_match_by_email option setting.
458 *
459 * @since jetpack-2.9
460 * @return int
461 **/
462 public function validate_jetpack_sso_match_by_email( $input ) {
463 return ( ! empty( $input ) ) ? 1 : 0;
464 }
465
466 /**
467 * Checks to determine if the user wants to login on wp-login
468 *
469 * This function mostly exists to cover the exceptions to login
470 * that may exist as other parameters to $_GET[action] as $_GET[action]
471 * does not have to exist. By default WordPress assumes login if an action
472 * is not set, however this may not be true, as in the case of logout
473 * where $_GET[loggedout] is instead set
474 *
475 * @return boolean
476 **/
477 private function wants_to_login() {
478 $wants_to_login = false;
479
480 // Cover default WordPress behavior.
481 $action = isset( $_REQUEST['action'] ) ? filter_var( wp_unslash( $_REQUEST['action'] ) ) : 'login'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
482
483 // And now the exceptions.
484 $action = isset( $_GET['loggedout'] ) ? 'loggedout' : $action; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
485
486 // Recovery mode must complete on the local site (token validation, cookie, recovery notice). Skip the bypass-redirect so SSO doesn't carry the user off-site mid-recovery.
487 if ( 'entered_recovery_mode' === $action ) {
488 return false;
489 }
490
491 if ( Helpers::display_sso_form_for_action( $action ) ) {
492 $wants_to_login = true;
493 }
494
495 return $wants_to_login;
496 }
497
498 /**
499 * Initialization for a SSO request.
500 */
501 public function login_init() {
502 global $action;
503
504 $tracking = new Tracking();
505
506 if ( Helpers::should_hide_login_form() ) {
507 /**
508 * Since the default authenticate filters fire at priority 20 for checking username and password,
509 * let's fire at priority 30. wp_authenticate_spam_check is fired at priority 99, but since we return a
510 * WP_Error in disable_default_login_form, then we won't trigger spam processing logic.
511 */
512 add_filter( 'authenticate', array( Notices::class, 'disable_default_login_form' ), 30 );
513
514 /**
515 * Filter the display of the disclaimer message appearing when default WordPress login form is disabled.
516 *
517 * @module sso
518 *
519 * @since jetpack-2.8.0
520 *
521 * @param bool true Should the disclaimer be displayed. Default to true.
522 */
523 $display_sso_disclaimer = apply_filters( 'jetpack_sso_display_disclaimer', true );
524 if ( $display_sso_disclaimer ) {
525 add_filter( 'login_message', array( Notices::class, 'msg_login_by_jetpack' ) );
526 }
527 }
528
529 if ( 'jetpack-sso' === $action ) {
530 if ( isset( $_GET['result'] ) && isset( $_GET['user_id'] ) && isset( $_GET['sso_nonce'] ) && 'success' === $_GET['result'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
531 $this->handle_login();
532 $this->display_sso_login_form();
533 } elseif ( ( new Status() )->in_safe_mode() ) {
534 add_filter( 'login_message', array( Notices::class, 'sso_not_allowed_in_safe_mode' ) );
535 } else {
536 // Is it wiser to just use wp_redirect than do this runaround to wp_safe_redirect?
537 add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
538 $reauth = ! empty( $_GET['force_reauth'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
539 $sso_url = $this->get_sso_url_or_die( $reauth );
540
541 $tracking->record_user_event( 'sso_login_redirect_success' );
542 wp_safe_redirect( $sso_url );
543 exit( 0 );
544 }
545 } elseif ( Helpers::display_sso_form_for_action( $action ) ) {
546
547 // Save cookies so we can handle redirects after SSO.
548 static::save_cookies();
549
550 /**
551 * Check to see if the site admin wants to automagically forward the user
552 * to the WordPress.com login page AND that the request to wp-login.php
553 * is not something other than login (Like logout!)
554 */
555 if ( Helpers::bypass_login_forward_wpcom() && $this->wants_to_login() ) {
556 add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
557 $reauth = ! empty( $_GET['force_reauth'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
558 $sso_url = $this->get_sso_url_or_die( $reauth );
559 $tracking->record_user_event( 'sso_login_redirect_bypass_success' );
560 wp_safe_redirect( $sso_url );
561 exit( 0 );
562 }
563
564 $this->display_sso_login_form();
565 }
566 }
567
568 /**
569 * Ensures that we can get a nonce from WordPress.com via XML-RPC before setting
570 * up the hooks required to display the SSO form.
571 */
572 public function display_sso_login_form() {
573 add_filter( 'login_body_class', array( $this, 'login_body_class' ) );
574 add_action( 'login_enqueue_scripts', array( $this, 'enqueue_login_styles' ) );
575
576 if ( ( new Status() )->in_safe_mode() ) {
577 add_filter( 'login_message', array( Notices::class, 'sso_not_allowed_in_safe_mode' ) );
578 return;
579 }
580
581 $sso_nonce = self::request_initial_nonce();
582 if ( is_wp_error( $sso_nonce ) ) {
583 return;
584 }
585
586 add_action( 'login_form', array( $this, 'login_form' ) );
587 add_action( 'login_enqueue_scripts', array( $this, 'login_enqueue_scripts' ) );
588 }
589
590 /**
591 * Conditionally save the redirect_to url as a cookie.
592 *
593 * @since jetpack-4.6.0 Renamed to save_cookies from maybe_save_redirect_cookies
594 */
595 public static function save_cookies() {
596 if ( headers_sent() ) {
597 return new WP_Error( 'headers_sent', __( 'Cannot deal with cookie redirects, as headers are already sent.', 'jetpack-connection' ) );
598 }
599
600 setcookie(
601 'jetpack_sso_original_request',
602 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff misses the wrapping esc_url_raw().
603 esc_url_raw( set_url_scheme( ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ) ) ),
604 time() + HOUR_IN_SECONDS,
605 COOKIEPATH,
606 COOKIE_DOMAIN,
607 is_ssl(),
608 true
609 );
610
611 // Persist the WordPress.com referrer signal so it survives the SSO button
612 // click, which changes the HTTP Referer to the site's own login page.
613 // Uses the live-only check to avoid a self-reinforcing cookie loop.
614 if ( self::is_live_referrer_wpcom() ) {
615 setcookie( 'jetpack_sso_wpcom_referrer', '1', time() + ( 10 * MINUTE_IN_SECONDS ), COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
616 $_COOKIE['jetpack_sso_wpcom_referrer'] = '1';
617 } elseif ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
618 setcookie( 'jetpack_sso_wpcom_referrer', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
619 unset( $_COOKIE['jetpack_sso_wpcom_referrer'] );
620 }
621
622 if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
623 // If we have something to redirect to.
624 $url = esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
625 setcookie( 'jetpack_sso_redirect_to', $url, time() + HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
626 } elseif ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
627 // Otherwise, if it's already set, purge it.
628 setcookie( 'jetpack_sso_redirect_to', ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
629 }
630 }
631
632 /**
633 * Outputs the Jetpack SSO button and description as well as the toggle link
634 * for switching between Jetpack SSO and default login.
635 */
636 public function login_form() {
637 $site_name = get_bloginfo( 'name' );
638 if ( ! $site_name ) {
639 $site_name = get_bloginfo( 'url' );
640 }
641
642 $display_name = ! empty( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] )
643 ? sanitize_text_field( wp_unslash( $_COOKIE[ 'jetpack_sso_wpcom_name_' . COOKIEHASH ] ) )
644 : false;
645 $gravatar = ! empty( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] )
646 ? esc_url_raw( wp_unslash( $_COOKIE[ 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH ] ) )
647 : false;
648
649 ?>
650 <div id="jetpack-sso-wrap">
651 <?php
652 /**
653 * Allow extension above Jetpack's SSO form.
654 *
655 * @module sso
656 *
657 * @since jetpack-8.6.0
658 */
659 do_action( 'jetpack_sso_login_form_above_wpcom' );
660
661 if ( $display_name && $gravatar ) :
662 ?>
663 <div id="jetpack-sso-wrap__user">
664 <img width="72" height="72" src="<?php echo esc_html( $gravatar ); ?>" />
665
666 <h2>
667 <?php
668 echo wp_kses(
669 /* translators: %s a user display name. */
670 sprintf( __( 'Log in as <span>%s</span>', 'jetpack-connection' ), esc_html( $display_name ) ),
671 array( 'span' => true )
672 );
673 ?>
674 </h2>
675 </div>
676
677 <?php endif; ?>
678
679
680 <div id="jetpack-sso-wrap__action">
681 <?php echo $this->build_sso_button( array(), true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaping done in build_sso_button() ?>
682
683 <?php if ( $display_name && $gravatar ) : ?>
684 <a rel="nofollow" class="jetpack-sso-wrap__reauth" href="<?php echo esc_url( $this->build_sso_button_url( array( 'force_reauth' => '1' ) ) ); ?>">
685 <?php esc_html_e( 'Log in with another WordPress.com account', 'jetpack-connection' ); ?>
686 </a>
687 <?php else : ?>
688 <p>
689 <?php
690 /**
691 * Filter the messeage displayed below the SSO button.
692 *
693 * @module sso
694 *
695 * @since jetpack-10.3.0
696 *
697 * @param string $sso_explanation Message displayed below the SSO button.
698 */
699 $sso_explanation = apply_filters(
700 'jetpack_sso_login_form_explanation_text',
701 sprintf(
702 /* Translators: %s is the name of the site. */
703 __( 'You can now save time spent logging in by connecting your WordPress.com account to %s.', 'jetpack-connection' ),
704 esc_html( $site_name )
705 )
706 );
707 echo esc_html( $sso_explanation );
708 ?>
709 </p>
710 <?php endif; ?>
711 </div>
712
713 <?php
714 /**
715 * Allow extension below Jetpack's SSO form.
716 *
717 * @module sso
718 *
719 * @since jetpack-8.6.0
720 */
721 do_action( 'jetpack_sso_login_form_below_wpcom' );
722
723 if ( ! Helpers::should_hide_login_form() ) :
724 ?>
725 <div class="jetpack-sso-or">
726 <span><?php esc_html_e( 'Or', 'jetpack-connection' ); ?></span>
727 </div>
728
729 <a href="<?php echo esc_url( add_query_arg( 'jetpack-sso-show-default-form', '1' ) ); ?>" class="jetpack-sso-toggle wpcom">
730 <?php
731 esc_html_e( 'Log in with username and password', 'jetpack-connection' )
732 ?>
733 </a>
734
735 <a href="<?php echo esc_url( add_query_arg( 'jetpack-sso-show-default-form', '0' ) ); ?>" class="jetpack-sso-toggle default">
736 <?php
737 esc_html_e( 'Log in with WordPress.com', 'jetpack-connection' )
738 ?>
739 </a>
740 <?php endif; ?>
741 </div>
742 <?php
743 }
744
745 /**
746 * Clear cookies that are no longer needed once the user has logged in.
747 *
748 * @since jetpack-4.8.0
749 */
750 public static function clear_cookies_after_login() {
751 Helpers::clear_wpcom_profile_cookies();
752 if ( isset( $_COOKIE['jetpack_sso_nonce'] ) ) {
753 setcookie(
754 'jetpack_sso_nonce',
755 ' ',
756 time() - YEAR_IN_SECONDS,
757 COOKIEPATH,
758 COOKIE_DOMAIN,
759 is_ssl(),
760 true
761 );
762 }
763
764 if ( isset( $_COOKIE['jetpack_sso_original_request'] ) ) {
765 setcookie(
766 'jetpack_sso_original_request',
767 ' ',
768 time() - YEAR_IN_SECONDS,
769 COOKIEPATH,
770 COOKIE_DOMAIN,
771 is_ssl(),
772 true
773 );
774 }
775
776 if ( isset( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
777 setcookie(
778 'jetpack_sso_redirect_to',
779 ' ',
780 time() - YEAR_IN_SECONDS,
781 COOKIEPATH,
782 COOKIE_DOMAIN,
783 is_ssl(),
784 true
785 );
786 }
787
788 if ( isset( $_COOKIE[ self::BROKER_COOKIE ] ) ) {
789 setcookie(
790 self::BROKER_COOKIE,
791 ' ',
792 time() - YEAR_IN_SECONDS,
793 COOKIEPATH,
794 COOKIE_DOMAIN,
795 is_ssl(),
796 true
797 );
798 }
799
800 if ( isset( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
801 setcookie(
802 'jetpack_sso_wpcom_referrer',
803 ' ',
804 time() - YEAR_IN_SECONDS,
805 COOKIEPATH,
806 COOKIE_DOMAIN,
807 is_ssl(),
808 true
809 );
810 }
811 }
812
813 /**
814 * Clean up after Jetpack gets disconnected.
815 *
816 * @since jetpack-10.7
817 */
818 public static function disconnect() {
819 if ( ( new Manager() )->is_user_connected() ) {
820 Helpers::delete_connection_for_user( get_current_user_id() );
821 }
822 }
823
824 /**
825 * Retrieves nonce used for SSO form.
826 *
827 * @return string|WP_Error
828 */
829 public static function request_initial_nonce() {
830 $nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] )
831 ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) )
832 : false;
833
834 if ( ! $nonce ) {
835 $xml = new Jetpack_IXR_Client();
836 $xml->query( 'jetpack.sso.requestNonce' );
837
838 if ( $xml->isError() ) {
839 return new WP_Error( $xml->getErrorCode(), $xml->getErrorMessage() );
840 }
841
842 $response = $xml->getResponse();
843
844 // The response may be a plain nonce string (default) or an associative
845 // array containing 'nonce' and a 'use_sso_broker' signal for sites that
846 // use an external SSO broker (e.g. CIAB stores via the MSD).
847 if ( is_array( $response ) ) {
848 if ( empty( $response['nonce'] ) ) {
849 return new WP_Error( 'invalid_response', __( 'Invalid nonce response from WordPress.com.', 'jetpack-connection' ) );
850 }
851
852 $nonce = sanitize_key( $response['nonce'] );
853 $use_broker = ! empty( $response['use_sso_broker'] );
854 } else {
855 $nonce = sanitize_key( $response );
856 $use_broker = false;
857 }
858
859 $cookie_expiry = time() + ( 10 * MINUTE_IN_SECONDS );
860
861 setcookie(
862 'jetpack_sso_nonce',
863 $nonce,
864 $cookie_expiry,
865 COOKIEPATH,
866 COOKIE_DOMAIN,
867 is_ssl(),
868 true
869 );
870 // Ensure this request can use the nonce immediately after setcookie().
871 $_COOKIE['jetpack_sso_nonce'] = $nonce;
872
873 if ( $use_broker ) {
874 setcookie(
875 self::BROKER_COOKIE,
876 $nonce,
877 $cookie_expiry,
878 COOKIEPATH,
879 COOKIE_DOMAIN,
880 is_ssl(),
881 true
882 );
883 // Mirror the broker signal in-memory for this request.
884 $_COOKIE[ self::BROKER_COOKIE ] = $nonce;
885 } else {
886 setcookie( self::BROKER_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
887 unset( $_COOKIE[ self::BROKER_COOKIE ] );
888 }
889 }
890
891 return $nonce;
892 }
893
894 /**
895 * Validates a broker URL string.
896 *
897 * @param string $url The URL to validate.
898 * @return string|false The URL if valid HTTPS with a host, or false.
899 */
900 private static function validate_broker_url( $url ) {
901 if ( empty( $url ) || ! is_string( $url ) ) {
902 return false;
903 }
904
905 $sanitized = esc_url_raw( $url );
906 $url_parts = wp_parse_url( $sanitized );
907
908 if ( $url_parts && 'https' === ( $url_parts['scheme'] ?? '' ) && ! empty( $url_parts['host'] ) ) {
909 return $sanitized;
910 }
911
912 return false;
913 }
914
915 /**
916 * Checks whether WP.com has authorized broker mode for the current SSO flow.
917 *
918 * The broker cookie is set during the nonce request when WP.com signals
919 * that broker SSO should be used. Its value matches the SSO nonce to tie
920 * the authorization to a specific flow.
921 *
922 * @return bool True if WP.com authorized broker mode for this nonce.
923 */
924 private static function is_broker_authorized() {
925 $broker_signal = ! empty( $_COOKIE[ self::BROKER_COOKIE ] )
926 ? sanitize_key( wp_unslash( $_COOKIE[ self::BROKER_COOKIE ] ) )
927 : false;
928 $current_nonce = ! empty( $_COOKIE['jetpack_sso_nonce'] )
929 ? sanitize_key( wp_unslash( $_COOKIE['jetpack_sso_nonce'] ) )
930 : false;
931
932 return $broker_signal && $current_nonce && $broker_signal === $current_nonce;
933 }
934
935 /**
936 * Checks whether the current request's referrer is a WordPress.com domain.
937 *
938 * Used to skip the broker URL when the user navigated from Calypso or
939 * another WordPress.com interface, so they stay within the expected
940 * wordpress.com SSO flow.
941 *
942 * @return bool True if the referrer is a WordPress.com domain.
943 */
944 private static function is_referrer_wpcom() {
945 // Check the cookie persisted by save_cookies() on the initial login page
946 // load. The live HTTP Referer changes to the site's own wp-login.php when
947 // the user clicks the SSO button, so the cookie carries the original signal.
948 if ( ! empty( $_COOKIE['jetpack_sso_wpcom_referrer'] ) ) {
949 return true;
950 }
951
952 return self::is_live_referrer_wpcom();
953 }
954
955 /**
956 * Checks the live HTTP Referer header against WordPress.com domains.
957 *
958 * Unlike is_referrer_wpcom(), this does NOT consult the persisted cookie,
959 * so it is safe to call from save_cookies() without creating a
960 * self-reinforcing loop.
961 *
962 * @return bool True if the live referrer is a WordPress.com domain.
963 */
964 private static function is_live_referrer_wpcom() {
965 $referer = wp_get_raw_referer();
966 if ( ! $referer ) {
967 return false;
968 }
969
970 $wpcom_hosts = array(
971 'wordpress.com',
972 'horizon.wordpress.com',
973 'wpcalypso.wordpress.com',
974 );
975
976 $referer_host = wp_parse_url( $referer, PHP_URL_HOST );
977 return $referer_host && in_array( $referer_host, $wpcom_hosts, true );
978 }
979
980 /**
981 * Retrieves the SSO broker URL if authorized by WP.com and defined by the MU plugin.
982 *
983 * The broker URL is read from the JETPACK_SSO_BROKER_URL constant, which
984 * is expected to be defined by a garden MU plugin (e.g. for CIAB stores).
985 * It is only used when WP.com has signaled broker mode via the nonce response.
986 *
987 * @return string|false The broker URL, or false if not available.
988 */
989 public static function get_broker_url() {
990 if ( ! self::is_broker_authorized() ) {
991 return false;
992 }
993 $url = Constants::get_constant( 'JETPACK_SSO_BROKER_URL' );
994 return $url ? self::validate_broker_url( $url ) : false;
995 }
996
997 /**
998 * Retrieves the SSO broker authorization URL if authorized by WP.com.
999 *
1000 * For broker sites, this URL replaces the Jetpack authorization endpoint
1001 * for establishing user connections. Read from the JETPACK_SSO_BROKER_AUTH_URL
1002 * constant defined by the garden MU plugin.
1003 *
1004 * @return string|false The broker authorization URL, or false if not available.
1005 */
1006 public static function get_broker_auth_url() {
1007 if ( ! self::is_broker_authorized() ) {
1008 return false;
1009 }
1010 $url = Constants::get_constant( 'JETPACK_SSO_BROKER_AUTH_URL' );
1011 return $url ? self::validate_broker_url( $url ) : false;
1012 }
1013
1014 /**
1015 * The function that actually handles the login!
1016 */
1017 public function handle_login() {
1018 $wpcom_nonce = isset( $_GET['sso_nonce'] ) ? sanitize_key( $_GET['sso_nonce'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1019 $wpcom_user_id = isset( $_GET['user_id'] ) ? (int) $_GET['user_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1020
1021 $token_lookup = $this->get_signed_user_token_for_wpcom_id( $wpcom_user_id );
1022 $signed_user_token = $token_lookup['signed_token'];
1023 $token_validated_for_user = $token_lookup['local_user_id'];
1024
1025 $xml = new Jetpack_IXR_Client();
1026 if ( $signed_user_token ) {
1027 $xml->query( 'jetpack.sso.validateResult', $wpcom_nonce, $wpcom_user_id, $signed_user_token );
1028 } else {
1029 $xml->query( 'jetpack.sso.validateResult', $wpcom_nonce, $wpcom_user_id );
1030 }
1031
1032 $user_data = $xml->isError() ? false : $xml->getResponse();
1033 if ( empty( $user_data ) ) {
1034 add_filter( 'jetpack_sso_default_to_sso_login', '__return_false' );
1035 add_filter( 'login_message', array( Notices::class, 'error_invalid_response_data' ) );
1036 return;
1037 }
1038
1039 $user_data = (object) $user_data;
1040 $user = null;
1041
1042 /**
1043 * Fires before Jetpack's SSO modifies the log in form.
1044 *
1045 * @module sso
1046 *
1047 * @since jetpack-2.6.0
1048 *
1049 * @param object $user_data WordPress.com User information.
1050 */
1051 do_action( 'jetpack_sso_pre_handle_login', $user_data );
1052
1053 $tracking = new Tracking();
1054
1055 if ( Helpers::is_two_step_required() && 0 === (int) $user_data->two_step_enabled ) {
1056 $this->user_data = $user_data;
1057
1058 $tracking->record_user_event(
1059 'sso_login_failed',
1060 array(
1061 'error_message' => 'error_msg_enable_two_step',
1062 )
1063 );
1064
1065 $error = new WP_Error( 'two_step_required', __( 'You must have Two-Step Authentication enabled on your WordPress.com account.', 'jetpack-connection' ) );
1066
1067 /** This filter is documented in core/src/wp-includes/pluggable.php */
1068 do_action( 'wp_login_failed', $user_data->login, $error );
1069 add_filter( 'login_message', array( Notices::class, 'error_msg_enable_two_step' ) );
1070 return;
1071 }
1072
1073 $user_found_with = '';
1074 if ( isset( $user_data->external_user_id ) ) {
1075 $user_found_with = 'external_user_id';
1076 $user = get_user_by( 'id', (int) $user_data->external_user_id );
1077 if ( $user ) {
1078 $expected_id = Utils::get_wpcom_user_id( $user->ID );
1079 if ( $expected_id && $expected_id !== (int) $user_data->ID ) {
1080 $error = new WP_Error( 'expected_wpcom_user', __( 'Something got a little mixed up and an unexpected WordPress.com user logged in.', 'jetpack-connection' ) );
1081
1082 $tracking->record_user_event(
1083 'sso_login_failed',
1084 array(
1085 'error_message' => 'error_unexpected_wpcom_user',
1086 )
1087 );
1088
1089 /** This filter is documented in core/src/wp-includes/pluggable.php */
1090 do_action( 'wp_login_failed', $user_data->login, $error );
1091 add_filter( 'login_message', array( Notices::class, 'error_invalid_response_data' ) ); // @todo Need to have a better notice. This is only for the sake of testing the validation.
1092 return;
1093 }
1094 self::set_wpcom_user_id_meta( $user->ID, $user_data->ID );
1095 }
1096 }
1097
1098 // If we don't have one by wpcom_user_id, try by the email?
1099 if ( empty( $user ) && Helpers::match_by_email() ) {
1100 $user_found_with = 'match_by_email';
1101 $user = get_user_by( 'email', $user_data->email );
1102 if ( $user ) {
1103 self::set_wpcom_user_id_meta( $user->ID, $user_data->ID );
1104 }
1105 }
1106
1107 // If we've still got nothing, create the user.
1108 $new_user_override_role = Helpers::new_user_override( $user_data );
1109 if ( empty( $user ) && ( get_option( 'users_can_register' ) || $new_user_override_role ) ) {
1110 /**
1111 * If not matching by email we still need to verify the email does not exist
1112 * or this blows up
1113 *
1114 * If match_by_email is true, we know the email doesn't exist, as it would have
1115 * been found in the first pass. If get_user_by( 'email' ) doesn't find the
1116 * user, then we know that email is unused, so it's safe to add.
1117 */
1118 if ( Helpers::match_by_email() || ! get_user_by( 'email', $user_data->email ) ) {
1119
1120 if ( $new_user_override_role ) {
1121 $user_data->role = $new_user_override_role;
1122 }
1123
1124 $user = Utils::generate_user( $user_data );
1125 if ( ! $user ) {
1126 $tracking->record_user_event(
1127 'sso_login_failed',
1128 array(
1129 'error_message' => 'could_not_create_username',
1130 )
1131 );
1132 add_filter( 'login_message', array( Notices::class, 'error_unable_to_create_user' ) );
1133 return;
1134 }
1135
1136 $user_found_with = $new_user_override_role
1137 ? 'user_created_new_user_override'
1138 : 'user_created_users_can_register';
1139 } else {
1140 $tracking->record_user_event(
1141 'sso_login_failed',
1142 array(
1143 'error_message' => 'error_msg_email_already_exists',
1144 )
1145 );
1146
1147 $this->user_data = $user_data;
1148 add_action( 'login_message', array( Notices::class, 'error_msg_email_already_exists' ) );
1149 return;
1150 }
1151 }
1152
1153 /**
1154 * Fires after we got login information from WordPress.com.
1155 *
1156 * @module sso
1157 *
1158 * @since jetpack-2.6.0
1159 *
1160 * @param WP_User|false|null $user Local User information.
1161 * @param object $user_data WordPress.com User Login information.
1162 */
1163 do_action( 'jetpack_sso_handle_login', $user, $user_data );
1164
1165 if ( $user ) {
1166 // Cache the user's details, so we can present it back to them on their user screen.
1167 update_user_meta( $user->ID, 'wpcom_user_data', $user_data );
1168
1169 /*
1170 * Two-Factor plugin 0.15.0+ unconditionally hooks wp_login at PHP_INT_MAX,
1171 * which destroys the auth session and prompts for local 2FA — even for SSO
1172 * logins that already completed 2FA on WordPress.com.
1173 *
1174 * When WP.com confirms the user has 2FA active, remove Two-Factor's wp_login
1175 * hook so SSO can complete without a redundant local 2FA prompt.
1176 *
1177 * When WP.com 2FA is NOT active, the hook stays and Two-Factor can enforce
1178 * local 2FA as a safety net.
1179 *
1180 * @see https://github.com/WordPress/two-factor/issues/811
1181 */
1182 /**
1183 * Filter whether to accept WordPress.com 2FA in place of a local
1184 * Two-Factor prompt during SSO login.
1185 *
1186 * Return false to always require the local Two-Factor prompt,
1187 * even when the user has completed 2FA on WordPress.com.
1188 *
1189 * @since 8.1.0
1190 * @module sso
1191 *
1192 * @param bool $accept Whether to accept WP.com 2FA. Default true.
1193 * @param object $user_data WordPress.com user data from SSO validation.
1194 * @param WP_User $user The local WordPress user.
1195 */
1196 $accept_wpcom_2fa = apply_filters( 'jetpack_sso_accept_wpcom_2fa', true, $user_data, $user );
1197
1198 if (
1199 ! empty( $user_data->two_step_enabled )
1200 && class_exists( 'Two_Factor_Core' )
1201 && $accept_wpcom_2fa
1202 ) {
1203 self::$sso_user_for_2fa = $user;
1204 add_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10, 2 );
1205
1206 remove_action( 'wp_login', array( 'Two_Factor_Core', 'wp_login' ), PHP_INT_MAX );
1207 }
1208
1209 add_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
1210 wp_set_auth_cookie( $user->ID, true );
1211 remove_filter( 'auth_cookie_expiration', array( Helpers::class, 'extend_auth_cookie_expiration_for_sso' ) );
1212 remove_filter( 'attach_session_information', array( static::class, 'add_two_factor_session_meta' ), 10 );
1213
1214 /** This filter is documented in core/src/wp-includes/user.php */
1215 do_action( 'wp_login', $user->user_login, $user );
1216
1217 wp_set_current_user( $user->ID );
1218
1219 $json_api_auth_environment = Helpers::get_json_api_auth_environment();
1220
1221 $is_json_api_auth = ! empty( $json_api_auth_environment );
1222 $manager = new Manager();
1223 $is_user_connected = $manager->is_user_connected( $user->ID );
1224
1225 if ( $is_user_connected ) {
1226 $is_user_connected = $this->verify_user_token(
1227 $user->ID,
1228 $user_data,
1229 $manager->get_tokens(),
1230 $token_validated_for_user
1231 );
1232 }
1233
1234 $roles = new Roles();
1235 $tracking->record_user_event(
1236 'sso_user_logged_in',
1237 array(
1238 'user_found_with' => $user_found_with,
1239 'user_connected' => (bool) $is_user_connected,
1240 'user_role' => $roles->translate_current_user_to_role(),
1241 'is_json_api_auth' => $is_json_api_auth,
1242 )
1243 );
1244
1245 $_request_redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1246 $redirect_to = user_can( $user, 'edit_posts' ) ? admin_url() : self::profile_page_url();
1247
1248 // If we have a saved redirect to request in a cookie.
1249 if ( ! empty( $_COOKIE['jetpack_sso_redirect_to'] ) ) {
1250 // Set that as the requested redirect to.
1251 $redirect_to = esc_url_raw( wp_unslash( $_COOKIE['jetpack_sso_redirect_to'] ) );
1252 $_request_redirect_to = $redirect_to;
1253 }
1254
1255 if ( $is_json_api_auth ) {
1256 $authorize_json_api = new Authorize_Json_Api();
1257 $authorize_json_api->verify_json_api_authorization_request( $json_api_auth_environment );
1258 $authorize_json_api->store_json_api_authorization_token( $user->user_login, $user );
1259
1260 } elseif ( ! $is_user_connected ) {
1261 $broker_auth_url = self::get_broker_auth_url();
1262 if ( $broker_auth_url ) {
1263 add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1264 wp_safe_redirect(
1265 add_query_arg(
1266 array(
1267 'action' => 'jetpack-sso',
1268 'site_id' => Manager::get_site_id( true ),
1269 'redirect_to' => $redirect_to,
1270 'request_redirect_to' => $_request_redirect_to,
1271 'broker-sso-auth-redirect' => '1',
1272 ),
1273 $broker_auth_url
1274 )
1275 );
1276 exit( 0 );
1277 }
1278
1279 wp_safe_redirect(
1280 add_query_arg(
1281 array(
1282 'redirect_to' => $redirect_to,
1283 'request_redirect_to' => $_request_redirect_to,
1284 'calypso_env' => ( new Host() )->get_calypso_env(),
1285 'jetpack-sso-auth-redirect' => '1',
1286 ),
1287 admin_url()
1288 )
1289 );
1290 exit( 0 );
1291 }
1292
1293 add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1294 wp_safe_redirect(
1295 /** This filter is documented in core/src/wp-login.php */
1296 apply_filters( 'login_redirect', $redirect_to, $_request_redirect_to, $user )
1297 );
1298 exit( 0 );
1299 }
1300
1301 add_filter( 'jetpack_sso_default_to_sso_login', '__return_false' );
1302
1303 $tracking->record_user_event(
1304 'sso_login_failed',
1305 array(
1306 'error_message' => 'cant_find_user',
1307 )
1308 );
1309
1310 $this->user_data = $user_data;
1311
1312 $error = new WP_Error( 'account_not_found', __( 'Account not found. If you already have an account, make sure you have connected to WordPress.com.', 'jetpack-connection' ) );
1313
1314 /** This filter is documented in core/src/wp-includes/pluggable.php */
1315 do_action( 'wp_login_failed', $user_data->login, $error );
1316 add_filter( 'login_message', array( Notices::class, 'cant_find_user' ) );
1317 }
1318
1319 /**
1320 * Retrieve the admin profile page URL.
1321 */
1322 public static function profile_page_url() {
1323 return admin_url( 'profile.php' );
1324 }
1325
1326 /**
1327 * Builds the "Login to WordPress.com" button that is displayed on the login page as well as user profile page.
1328 *
1329 * @param array $args An array of arguments to add to the SSO URL.
1330 * @param boolean $is_primary If the button have the `button-primary` class.
1331 * @return string Returns the HTML markup for the button.
1332 */
1333 public function build_sso_button( $args = array(), $is_primary = false ) {
1334 $url = $this->build_sso_button_url( $args );
1335 $classes = $is_primary
1336 ? 'jetpack-sso button button-primary'
1337 : 'jetpack-sso button';
1338
1339 return sprintf(
1340 '<a rel="nofollow" href="%1$s" class="%2$s">%3$s %4$s</a>',
1341 esc_url( $url ),
1342 $classes,
1343 '<span class="genericon genericon-wordpress"></span>',
1344 esc_html__( 'Log in with WordPress.com', 'jetpack-connection' )
1345 );
1346 }
1347
1348 /**
1349 * Builds a URL with `jetpack-sso` action and option args which is used to setup SSO.
1350 *
1351 * @param array $args An array of arguments to add to the SSO URL.
1352 * @return string The URL used for SSO.
1353 */
1354 public function build_sso_button_url( $args = array() ) {
1355 $defaults = array(
1356 'action' => 'jetpack-sso',
1357 );
1358
1359 $args = wp_parse_args( $args, $defaults );
1360
1361 if ( ! empty( $_GET['redirect_to'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1362 $args['redirect_to'] = rawurlencode( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1363 }
1364
1365 return add_query_arg( $args, wp_login_url() );
1366 }
1367
1368 /**
1369 * Retrieves a WordPress.com SSO URL with appropriate query parameters or dies.
1370 *
1371 * @param boolean $reauth If the user be forced to reauthenticate on WordPress.com.
1372 * @param array $args Optional query parameters.
1373 * @return string The WordPress.com SSO URL.
1374 */
1375 public function get_sso_url_or_die( $reauth = false, $args = array() ) {
1376 $custom_login_url = Helpers::get_custom_login_url();
1377 if ( $custom_login_url ) {
1378 $args['login_url'] = rawurlencode( $custom_login_url );
1379 }
1380
1381 if ( empty( $reauth ) ) {
1382 $sso_redirect = $this->build_sso_url( $args );
1383 } else {
1384 Helpers::clear_wpcom_profile_cookies();
1385 $sso_redirect = $this->build_reauth_and_sso_url( $args );
1386 }
1387
1388 // If there was an error retrieving the SSO URL, then error.
1389 if ( is_wp_error( $sso_redirect ) ) {
1390 $error_message = sanitize_text_field(
1391 sprintf( '%s: %s', $sso_redirect->get_error_code(), $sso_redirect->get_error_message() )
1392 );
1393 $tracking = new Tracking();
1394 $tracking->record_user_event(
1395 'sso_login_redirect_failed',
1396 array(
1397 'error_message' => $error_message,
1398 )
1399 );
1400 wp_die( esc_html( $error_message ) );
1401 }
1402
1403 return $sso_redirect;
1404 }
1405
1406 /**
1407 * Returns the base URL for SSO authentication.
1408 *
1409 * If a broker URL is available (authorized by WP.com and defined by the
1410 * garden MU plugin), that URL is used unless the user navigated from a
1411 * WordPress.com domain. Otherwise falls back to the default WordPress.com
1412 * login URL.
1413 *
1414 * @return string The base SSO URL.
1415 */
1416 public static function get_sso_base_url() {
1417 $broker_url = self::get_broker_url();
1418 if ( $broker_url && ! self::is_referrer_wpcom() ) {
1419 return $broker_url;
1420 }
1421 return 'https://wordpress.com/wp-login.php';
1422 }
1423
1424 /**
1425 * Build SSO URL with appropriate query parameters.
1426 *
1427 * The base URL can be WordPress.com or an authorized broker URL.
1428 *
1429 * @param array $args Optional query parameters.
1430 * @return string|WP_Error Redirect URL for SSO authentication.
1431 */
1432 public function build_sso_url( $args = array() ) {
1433 $sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
1434 $defaults = array(
1435 'action' => 'jetpack-sso',
1436 'site_id' => Manager::get_site_id( true ),
1437 'sso_nonce' => $sso_nonce,
1438 'calypso_auth' => '1',
1439 );
1440
1441 $args = wp_parse_args( $args, $defaults );
1442
1443 if ( is_wp_error( $sso_nonce ) ) {
1444 return $sso_nonce;
1445 }
1446
1447 return add_query_arg( $args, self::get_sso_base_url() );
1448 }
1449
1450 /**
1451 * Build SSO URL with appropriate query parameters, including the
1452 * parameters necessary to force the user to reauthenticate.
1453 *
1454 * @param array $args Optional query parameters.
1455 * @return string|WP_Error Redirect URL for SSO authentication.
1456 */
1457 public function build_reauth_and_sso_url( $args = array() ) {
1458 $sso_nonce = ! empty( $args['sso_nonce'] ) ? $args['sso_nonce'] : self::request_initial_nonce();
1459 $redirect = $this->build_sso_url(
1460 array(
1461 'force_auth' => '1',
1462 'sso_nonce' => $sso_nonce,
1463 )
1464 );
1465
1466 if ( is_wp_error( $redirect ) ) {
1467 return $redirect;
1468 }
1469
1470 $defaults = array(
1471 'action' => 'jetpack-sso',
1472 'site_id' => Manager::get_site_id( true ),
1473 'sso_nonce' => $sso_nonce,
1474 'reauth' => '1',
1475 'redirect_to' => rawurlencode( $redirect ),
1476 'calypso_auth' => '1',
1477 );
1478
1479 $args = wp_parse_args( $args, $defaults );
1480
1481 if ( is_wp_error( $args['sso_nonce'] ) ) {
1482 return $args['sso_nonce'];
1483 }
1484
1485 return add_query_arg( $args, self::get_sso_base_url() );
1486 }
1487
1488 /**
1489 * Sets the wpcom_user_id meta on a local user.
1490 *
1491 * @since 8.6.0
1492 *
1493 * @param int $user_id The local WordPress user ID to set the meta on.
1494 * @param int $wpcom_user_id The WordPress.com user ID.
1495 */
1496 private static function set_wpcom_user_id_meta( $user_id, $wpcom_user_id ) {
1497 Utils::set_wpcom_user_id( $user_id, $wpcom_user_id );
1498 }
1499
1500 /**
1501 * Determines local user associated with a given WordPress.com user ID.
1502 *
1503 * @since jetpack-2.6.0
1504 *
1505 * @param int $wpcom_user_id User ID from WordPress.com.
1506 * @return null|object Local user object if found, null if not.
1507 */
1508 public static function get_user_by_wpcom_id( $wpcom_user_id ) {
1509 $user_query = new WP_User_Query(
1510 array(
1511 'meta_key' => 'wpcom_user_id',
1512 'meta_value' => (int) $wpcom_user_id,
1513 'number' => 1,
1514 )
1515 );
1516
1517 $users = $user_query->get_results();
1518 return $users ? array_shift( $users ) : null;
1519 }
1520
1521 /**
1522 * Retrieves the signed user token for a given WP.com user ID, if one exists locally.
1523 *
1524 * Looks up the local WordPress user associated with the WP.com user ID and returns
1525 * a signed representation of their user token along with the local user ID.
1526 * The signed token is sent to WP.com during SSO validation so WP.com can verify
1527 * the token is still valid on its side.
1528 *
1529 * @since 8.6.0
1530 *
1531 * @param int $wpcom_user_id The WordPress.com user ID.
1532 * @return array{signed_token: string, local_user_id: int} The signed token and local user ID.
1533 * Both values are 0/empty when no valid token exists.
1534 */
1535 private function get_signed_user_token_for_wpcom_id( $wpcom_user_id ) {
1536 $result = array(
1537 'signed_token' => '',
1538 'local_user_id' => 0,
1539 );
1540
1541 if ( ! $wpcom_user_id ) {
1542 return $result;
1543 }
1544
1545 $local_user = self::get_user_by_wpcom_id( $wpcom_user_id );
1546 if ( ! $local_user ) {
1547 return $result;
1548 }
1549
1550 $tokens = new Tokens();
1551 $user_token = $tokens->get_access_token( $local_user->ID );
1552 if ( ! $user_token ) {
1553 return $result;
1554 }
1555
1556 $signed = $tokens->get_signed_token( $user_token );
1557 if ( is_wp_error( $signed ) ) {
1558 return $result;
1559 }
1560
1561 $result['signed_token'] = $signed;
1562 $result['local_user_id'] = $local_user->ID;
1563 return $result;
1564 }
1565
1566 /**
1567 * Verifies that a locally-stored user token is still valid on WP.com.
1568 *
1569 * Uses the `user_token_valid` field from the SSO validate response when the
1570 * signed token was sent for the same user that was resolved during login.
1571 *
1572 * When the validate response can't be trusted for this user (a different user
1573 * was resolved, or no signed token was sent), login proceeds without an extra
1574 * verification call. In that case `set_wpcom_user_id_meta()` records the
1575 * mapping during this login, so the fast path validates the token on the next
1576 * SSO login. This avoids an extra HTTP request on every first-SSO login and
1577 * keeps the Error_Handler from being triggered for users whose token state can
1578 * only be resolved by a direct token-health check.
1579 *
1580 * If the token is found to be invalid, it is removed locally so the user will be
1581 * prompted to re-authorize and obtain a fresh token.
1582 *
1583 * @since 8.6.0
1584 *
1585 * @param int $user_id The local WordPress user ID (the resolved user).
1586 * @param object $user_data The WP.com user data from jetpack.sso.validateResult.
1587 * @param Tokens $tokens The Tokens instance.
1588 * @param int $token_validated_for_user The local user ID whose token was sent to WP.com, or 0 if none.
1589 * @return bool True if the user token is valid (or could not be verified), false if invalid and removed.
1590 */
1591 private function verify_user_token( $user_id, $user_data, Tokens $tokens, $token_validated_for_user ) {
1592 // Only trust the validateResult response if the signed token was for this same user.
1593 if ( $token_validated_for_user === $user_id && isset( $user_data->user_token_valid ) ) {
1594 if ( false === $user_data->user_token_valid ) {
1595 $tokens->disconnect_user( $user_id );
1596 return false;
1597 }
1598 return true;
1599 }
1600
1601 // The signed token was for a different user (or wasn't sent at all), so the
1602 // validateResult response can't be trusted for this user. Let login proceed;
1603 // the wpcom_user_id meta set during this login means the next SSO login will
1604 // validate the token via the fast path.
1605 return true;
1606 }
1607
1608 /**
1609 * When jetpack-sso-auth-redirect query parameter is set, will redirect user to
1610 * WordPress.com authorization flow.
1611 *
1612 * We redirect here instead of in handle_login() because Jetpack::init()->build_connect_url
1613 * calls menu_page_url() which doesn't work properly until admin menus are registered.
1614 */
1615 public function maybe_authorize_user_after_sso() {
1616 if ( empty( $_GET['jetpack-sso-auth-redirect'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1617 return;
1618 }
1619
1620 $redirect_to = ! empty( $_GET['redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1621 $request_redirect_to = ! empty( $_GET['request_redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['request_redirect_to'] ) ) : $redirect_to; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1622
1623 /** This filter is documented in core/src/wp-login.php */
1624 $redirect_after_auth = apply_filters( 'login_redirect', $redirect_to, $request_redirect_to, wp_get_current_user() );
1625
1626 /**
1627 * Since we are passing this redirect to WordPress.com and therefore cannot use wp_safe_redirect(),
1628 * let's sanitize it here to make sure it's safe. If the redirect is not safe, then use admin_url().
1629 */
1630 $redirect_after_auth = wp_sanitize_redirect( $redirect_after_auth );
1631 $redirect_after_auth = wp_validate_redirect( $redirect_after_auth, admin_url() );
1632
1633 /**
1634 * Return the raw connect URL with our redirect and attribute connection to SSO.
1635 * We remove any other filters that may be turning on the in-place connection
1636 * since we will be redirecting the user as opposed to iFraming.
1637 */
1638 remove_all_filters( 'jetpack_use_iframe_authorization_flow' );
1639 add_filter( 'jetpack_use_iframe_authorization_flow', '__return_false' );
1640
1641 $connection = new Manager( 'jetpack-connection' );
1642 $connect_url = ( new Authorize_Redirect( $connection ) )->build_authorize_url( $redirect_after_auth, 'sso', true );
1643
1644 add_filter( 'allowed_redirect_hosts', array( Helpers::class, 'allowed_redirect_hosts' ) );
1645 wp_safe_redirect( $connect_url );
1646 exit( 0 );
1647 }
1648
1649 /**
1650 * Cache user's display name and Gravatar so it can be displayed on the login screen. These cookies are
1651 * stored when the user logs out, and then deleted when the user logs in.
1652 */
1653 public function store_wpcom_profile_cookies_on_logout() {
1654 $user_id = get_current_user_id();
1655 if ( ! ( new Manager() )->is_user_connected( $user_id ) ) {
1656 return;
1657 }
1658
1659 $user_data = $this->get_user_data( $user_id );
1660 if ( ! $user_data ) {
1661 return;
1662 }
1663
1664 setcookie(
1665 'jetpack_sso_wpcom_name_' . COOKIEHASH,
1666 $user_data->display_name,
1667 time() + WEEK_IN_SECONDS,
1668 COOKIEPATH,
1669 COOKIE_DOMAIN,
1670 is_ssl(),
1671 true
1672 );
1673
1674 setcookie(
1675 'jetpack_sso_wpcom_gravatar_' . COOKIEHASH,
1676 get_avatar_url(
1677 $user_data->email,
1678 array(
1679 'size' => 144,
1680 'default' => 'mystery',
1681 )
1682 ),
1683 time() + WEEK_IN_SECONDS,
1684 COOKIEPATH,
1685 COOKIE_DOMAIN,
1686 is_ssl(),
1687 true
1688 );
1689 }
1690
1691 /**
1692 * Determines if a local user is connected to WordPress.com
1693 *
1694 * @since jetpack-2.8
1695 * @param integer $user_id - Local user id.
1696 * @return boolean
1697 **/
1698 public function is_user_connected( $user_id ) {
1699 return $this->get_user_data( $user_id );
1700 }
1701
1702 /**
1703 * Retrieves a user's WordPress.com data
1704 *
1705 * @since jetpack-2.8
1706 * @param integer $user_id - Local user id.
1707 * @return mixed null or stdClass
1708 **/
1709 public function get_user_data( $user_id ) {
1710 return get_user_meta( $user_id, 'wpcom_user_data', true );
1711 }
1712
1713 /**
1714 * Marks a session as two-factor-authenticated when SSO handled 2FA via WP.com.
1715 *
1716 * @param array $session Session information array.
1717 * @param int $user_id User ID for the session being created.
1718 * @return array Modified session information.
1719 */
1720 public static function add_two_factor_session_meta( $session, $user_id ) {
1721 if ( self::$sso_user_for_2fa && self::$sso_user_for_2fa->ID === $user_id ) {
1722 $session['two-factor-login'] = time();
1723 self::$sso_user_for_2fa = null;
1724 }
1725 return $session;
1726 }
1727 }
1728