Skip to content

Commit

Permalink
Reconnect Process: Partial Reconnect (#16577)
Browse files Browse the repository at this point in the history
- Blog and user tokens get validated via the jetpack-token-health WP.com endpoint.
- If the user token is invalid, it gets refreshed.
- If the blog token is invalid, it gets refreshed via the jetpack-refresh-blog-token WP.com endpoint.
- If both tokens are valid, it means we weren't able to determine the reason for connection errors, and we perform the full reconnect.
  • Loading branch information
sergeymitr authored Aug 14, 2020
1 parent f87938f commit be0708a
Show file tree
Hide file tree
Showing 7 changed files with 652 additions and 26 deletions.
2 changes: 1 addition & 1 deletion _inc/client/state/connection/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export const reconnectSite = ( action = 'reconnect' ) => {
dispatch( {
type: SITE_RECONNECT_SUCCESS,
} );
dispatch( removeNotice( 'reconnect-jetpack' ) );
window.location.reload();
}
} )
.catch( error => {
Expand Down
3 changes: 2 additions & 1 deletion packages/connection/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"automattic/jetpack-constants": "@dev",
"automattic/jetpack-options": "@dev",
"automattic/jetpack-roles": "@dev",
"automattic/jetpack-status": "@dev"
"automattic/jetpack-status": "@dev",
"automattic/jetpack-tracking": "@dev"
},
"require-dev": {
"phpunit/phpunit": "^5.7 || ^6.5 || ^7.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/connection/src/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ public static function _stringify_data( $data ) { // phpcs:ignore PSR2.Methods.M
return (string) $data;
}

foreach ( $data as $key => &$value ) {
foreach ( $data as &$value ) {
$value = self::_stringify_data( $value );
}

Expand Down
233 changes: 229 additions & 4 deletions packages/connection/src/class-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
use Automattic\Jetpack\Roles;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Tracking;
use Jetpack_Options;
use WP_Error;
use WP_User;

/**
* The Jetpack Connection Manager class that is used as a single gateway between WordPress.com
Expand Down Expand Up @@ -692,17 +694,18 @@ public function connect_user( $user_id = null, $redirect_url = null ) {
* @todo Refactor to properly load the XMLRPC client independently.
*
* @param Integer $user_id the user identifier.
* @param bool $can_overwrite_primary_user Allow for the primary user to be disconnected.
* @return Boolean Whether the disconnection of the user was successful.
*/
public static function disconnect_user( $user_id = null ) {
$tokens = \Jetpack_Options::get_option( 'user_tokens' );
public static function disconnect_user( $user_id = null, $can_overwrite_primary_user = false ) {
$tokens = Jetpack_Options::get_option( 'user_tokens' );
if ( ! $tokens ) {
return false;
}

$user_id = empty( $user_id ) ? get_current_user_id() : intval( $user_id );

if ( \Jetpack_Options::get_option( 'master_user' ) === $user_id ) {
if ( Jetpack_Options::get_option( 'master_user' ) === $user_id && ! $can_overwrite_primary_user ) {
return false;
}

Expand All @@ -715,7 +718,7 @@ public static function disconnect_user( $user_id = null ) {

unset( $tokens[ $user_id ] );

\Jetpack_Options::update_option( 'user_tokens', $tokens );
Jetpack_Options::update_option( 'user_tokens', $tokens );

// Delete cached connected user data.
$transient_key = "jetpack_connected_user_data_$user_id";
Expand Down Expand Up @@ -1457,12 +1460,107 @@ public function remove_connection() {
* @return true|WP_Error True if reconnected successfully, a `WP_Error` object otherwise.
*/
public function reconnect() {
( new Tracking() )->record_user_event( 'restore_connection_reconnect' );

$this->disconnect_site_wpcom( true );
$this->delete_all_connection_tokens( true );

return $this->register();
}

/**
* Validate the tokens, and refresh the invalid ones.
*
* @return string|true|WP_Error True if connection restored or string indicating what's to be done next. A `WP_Error` object otherwise.
*/
public function restore() {
$invalid_tokens = array();
$can_restore = $this->can_restore( $invalid_tokens );

// Tokens are valid. We can't fix the problem we don't see, so the full reconnection is needed.
if ( ! $can_restore ) {
$result = $this->reconnect();
return true === $result ? 'authorize' : $result;
}

if ( in_array( 'blog', $invalid_tokens, true ) ) {
return self::refresh_blog_token();
}

if ( in_array( 'user', $invalid_tokens, true ) ) {
return true === self::refresh_user_token() ? 'authorize' : false;
}

return false;
}

/**
* Determine whether we can restore the connection, or the full reconnect is needed.
*
* @param array $invalid_tokens The array the invalid tokens are stored in, provided by reference.
*
* @return bool `True` if the connection can be restored, `false` otherwise.
*/
public function can_restore( &$invalid_tokens ) {
$invalid_tokens = array();

$validated_tokens = $this->validate_tokens();

if ( ! is_array( $validated_tokens ) || count( array_diff_key( array_flip( array( 'blog_token', 'user_token' ) ), $validated_tokens ) ) ) {
return false;
}

if ( empty( $validated_tokens['blog_token']['is_healthy'] ) ) {
$invalid_tokens[] = 'blog';
}

if ( empty( $validated_tokens['user_token']['is_healthy'] ) ) {
$invalid_tokens[] = 'user';
}

// If both tokens are invalid, we can't restore the connection.
return 1 === count( $invalid_tokens );
}

/**
* Perform the API request to validate the blog and user tokens.
*
* @param int|null $user_id ID of the user we need to validate token for. Current user's ID by default.
*
* @return array|false|WP_Error The API response: `array( 'blog_token_is_healthy' => true|false, 'user_token_is_healthy' => true|false )`.
*/
public function validate_tokens( $user_id = null ) {
$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}
$url = sprintf(
'%s://%s/%s/v%s/%s',
Client::protocol(),
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_HOST' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-token-health'
);

$user_token = $this->get_access_token( $user_id ? $user_id : get_current_user_id() );
$blog_token = $this->get_access_token();
$method = 'POST';
$body = array(
'user_token' => $this->get_signed_token( $user_token ),
'blog_token' => $this->get_signed_token( $blog_token ),
);
$response = Client::_wp_remote_request( $url, compact( 'body', 'method' ) );

if ( is_wp_error( $response ) || ! wp_remote_retrieve_body( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
}

$body = json_decode( wp_remote_retrieve_body( $response ), true );

return $body ? $body : false;
}

/**
* Responds to a WordPress.com call to register the current site.
* Should be changed to protected.
Expand Down Expand Up @@ -2453,4 +2551,131 @@ public function is_plugin_enabled() {
return $this->plugin->is_enabled();
}

/**
* Perform the API request to refresh the blog token.
* Note that we are making this request on behalf of the Jetpack master user,
* given they were (most probably) the ones that registered the site at the first place.
*
* @return WP_Error|bool The result of updating the blog_token option.
*/
public static function refresh_blog_token() {
( new Tracking() )->record_user_event( 'restore_connection_refresh_blog_token' );

$blog_id = Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site_not_registered', 'Site not registered.' );
}

$url = sprintf(
'%s://%s/%s/v%s/%s',
Client::protocol(),
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_HOST' ),
'wpcom',
'2',
'sites/' . $blog_id . '/jetpack-refresh-blog-token'
);
$method = 'POST';
$user_id = get_current_user_id();

$response = Client::remote_request( compact( 'url', 'method', 'user_id' ) );

if ( is_wp_error( $response ) ) {
return new WP_Error( 'refresh_blog_token_http_request_failed', $response->get_error_message() );
}

$code = wp_remote_retrieve_response_code( $response );
$entity = wp_remote_retrieve_body( $response );

if ( $entity ) {
$json = json_decode( $entity );
} else {
$json = false;
}

if ( 200 !== $code ) {
if ( empty( $json->code ) ) {
return new WP_Error( 'unknown', '', $code );
}

/* translators: Error description string. */
$error_description = isset( $json->message ) ? sprintf( __( 'Error Details: %s', 'jetpack' ), (string) $json->message ) : '';

return new WP_Error( (string) $json->code, $error_description, $code );
}

if ( empty( $json->jetpack_secret ) || ! is_scalar( $json->jetpack_secret ) ) {
return new WP_Error( 'jetpack_secret', '', $code );
}

return Jetpack_Options::update_option( 'blog_token', (string) $json->jetpack_secret );
}

/**
* Disconnect the user from WP.com, and initiate the reconnect process.
*
* @return bool
*/
public static function refresh_user_token() {
( new Tracking() )->record_user_event( 'restore_connection_refresh_user_token' );

self::disconnect_user( null, true );

return true;
}

/**
* Fetches a signed token.
*
* @param object $token the token.
* @return WP_Error|string a signed token
*/
public function get_signed_token( $token ) {
if ( ! isset( $token->secret ) || empty( $token->secret ) ) {
return new WP_Error( 'invalid_token' );
}

list( $token_key, $token_secret ) = explode( '.', $token->secret );

$token_key = sprintf(
'%s:%d:%d',
$token_key,
Constants::get_constant( 'JETPACK__API_VERSION' ),
$token->external_user_id
);

$timestamp = time();

if ( function_exists( 'wp_generate_password' ) ) {
$nonce = wp_generate_password( 10, false );
} else {
$nonce = substr( sha1( wp_rand( 0, 1000000 ) ), 0, 10 );
}

$normalized_request_string = join(
"\n",
array(
$token_key,
$timestamp,
$nonce,
)
) . "\n";

// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$signature = base64_encode( hash_hmac( 'sha1', $normalized_request_string, $token_secret, true ) );

$auth = array(
'token' => $token_key,
'timestamp' => $timestamp,
'nonce' => $nonce,
'signature' => $signature,
);

$header_pieces = array();
foreach ( $auth as $key => $value ) {
$header_pieces[] = sprintf( '%s="%s"', $key, $value );
}

return join( ' ', $header_pieces );
}

}
36 changes: 31 additions & 5 deletions packages/connection/src/class-rest-connector.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public function __construct( Manager $connection ) {
'required' => true,
),
),
'permission_callback' => __CLASS__ . '::jetpack_disconnect_permission_check',
'permission_callback' => __CLASS__ . '::jetpack_reconnect_permission_check',
)
);
}
Expand Down Expand Up @@ -229,8 +229,8 @@ public static function activate_plugins_permission_check() {
*
* @return bool|WP_Error Whether user has the capability 'jetpack_disconnect'.
*/
public static function jetpack_disconnect_permission_check() {
if ( current_user_can( 'jetpack_disconnect' ) ) {
public static function jetpack_reconnect_permission_check() {
if ( current_user_can( 'jetpack_reconnect' ) ) {
return true;
}

Expand Down Expand Up @@ -260,13 +260,26 @@ public function connection_reconnect( WP_REST_Request $request ) {

$response = array();

$next = null;

switch ( $params['action'] ) {
case 'reconnect':
$result = $this->connection->restore();

if ( is_wp_error( $result ) ) {
$response = $result;
} elseif ( is_string( $result ) ) {
$next = $result;
} else {
$next = true === $result ? 'completed' : 'failed';
}

break;
case 'reconnect_force':
$result = $this->connection->reconnect();

if ( true === $result ) {
$response['status'] = 'in_progress';
$response['authorizeUrl'] = $this->connection->get_authorization_url();
$next = 'authorize';
} elseif ( is_wp_error( $result ) ) {
$response = $result;
}
Expand All @@ -276,6 +289,19 @@ public function connection_reconnect( WP_REST_Request $request ) {
break;
}

switch ( $next ) {
case 'authorize':
$response['status'] = 'in_progress';
$response['authorizeUrl'] = $this->connection->get_authorization_url();
break;
case 'completed':
$response['status'] = 'completed';
break;
case 'failed':
$response = new WP_Error( 'Reconnect failed' );
break;
}

return rest_ensure_response( $response );
}

Expand Down
Loading

0 comments on commit be0708a

Please sign in to comment.