2022-07-28 18:42:13 +00:00
< ? php
/**
* The Jetpack Connection Tokens class file .
*
* @ package automattic / jetpack - connection
*/
namespace Automattic\Jetpack\Connection ;
use Automattic\Jetpack\Constants ;
use Automattic\Jetpack\Roles ;
use DateInterval ;
use DateTime ;
use Exception ;
use Jetpack_Options ;
use WP_Error ;
/**
* The Jetpack Connection Tokens class that manages tokens .
*/
class Tokens {
const MAGIC_NORMAL_TOKEN_KEY = ';normal;' ;
/**
* Datetime format .
*/
const DATE_FORMAT_ATOM = 'Y-m-d\TH:i:sP' ;
/**
* Deletes all connection tokens and transients from the local Jetpack site .
*/
public function delete_all () {
Jetpack_Options :: delete_option (
array (
'blog_token' ,
'user_token' ,
'user_tokens' ,
)
);
$this -> remove_lock ();
}
/**
* 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 ( $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/v%s/%s' ,
Constants :: get_constant ( 'JETPACK__WPCOM_JSON_API_BASE' ),
'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 ();
// Cannot validate non-existent tokens.
if ( false === $user_token || false === $blog_token ) {
return false ;
}
$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 ;
}
/**
* Perform the API request to validate only the blog .
*
* @ return bool | WP_Error Boolean with the test result . WP_Error if test cannot be performed .
*/
public function validate_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/v%s/%s' ,
Constants :: get_constant ( 'JETPACK__WPCOM_JSON_API_BASE' ),
'wpcom' ,
'2' ,
'sites/' . $blog_id . '/jetpack-token-health/blog'
);
$method = 'GET' ;
$response = Client :: remote_request ( compact ( 'url' , '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 is_array ( $body ) && isset ( $body [ 'is_healthy' ] ) && true === $body [ 'is_healthy' ];
}
/**
* Obtains the auth token .
*
* @ param array $data The request data .
* @ param string $token_api_url The URL of the Jetpack " token " API .
* @ return object | WP_Error Returns the auth token on success .
* Returns a WP_Error on failure .
*/
public function get ( $data , $token_api_url ) {
$roles = new Roles ();
$role = $roles -> translate_current_user_to_role ();
if ( ! $role ) {
return new WP_Error ( 'role' , __ ( 'An administrator for this blog must set up the Jetpack connection.' , 'jetpack-connection' ) );
}
$client_secret = $this -> get_access_token ();
if ( ! $client_secret ) {
return new WP_Error ( 'client_secret' , __ ( 'You need to register your Jetpack before connecting it.' , 'jetpack-connection' ) );
}
/**
* Filter the URL of the first time the user gets redirected back to your site for connection
* data processing .
*
* @ since 1.7 . 0
* @ since - jetpack 8.0 . 0
*
* @ param string $redirect_url Defaults to the site admin URL .
*/
$processing_url = apply_filters ( 'jetpack_token_processing_url' , admin_url ( 'admin.php' ) );
$redirect = isset ( $data [ 'redirect' ] ) ? esc_url_raw ( ( string ) $data [ 'redirect' ] ) : '' ;
/**
* Filter the URL to redirect the user back to when the authentication process
* is complete .
*
* @ since 1.7 . 0
* @ since - jetpack 8.0 . 0
*
* @ param string $redirect_url Defaults to the site URL .
*/
$redirect = apply_filters ( 'jetpack_token_redirect_url' , $redirect );
$redirect_uri = ( 'calypso' === $data [ 'auth_type' ] )
? $data [ 'redirect_uri' ]
: add_query_arg (
array (
'handler' => 'jetpack-connection-webhooks' ,
'action' => 'authorize' ,
'_wpnonce' => wp_create_nonce ( " jetpack-authorize_ { $role } _ { $redirect } " ),
'redirect' => $redirect ? rawurlencode ( $redirect ) : false ,
),
esc_url ( $processing_url )
);
/**
* Filters the token request data .
*
* @ since 1.7 . 0
* @ since - jetpack 8.0 . 0
*
* @ param array $request_data request data .
*/
$body = apply_filters (
'jetpack_token_request_body' ,
array (
'client_id' => Jetpack_Options :: get_option ( 'id' ),
'client_secret' => $client_secret -> secret ,
'grant_type' => 'authorization_code' ,
'code' => $data [ 'code' ],
'redirect_uri' => $redirect_uri ,
)
);
$args = array (
'method' => 'POST' ,
'body' => $body ,
'headers' => array (
'Accept' => 'application/json' ,
),
);
add_filter ( 'http_request_timeout' , array ( $this , 'return_30' ), PHP_INT_MAX - 1 );
$response = Client :: _wp_remote_request ( $token_api_url , $args );
remove_filter ( 'http_request_timeout' , array ( $this , 'return_30' ), PHP_INT_MAX - 1 );
if ( is_wp_error ( $response ) ) {
return new WP_Error ( '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 || ! empty ( $json -> error ) ) {
if ( empty ( $json -> error ) ) {
return new WP_Error ( 'unknown' , '' , $code );
}
/* translators: Error description string. */
$error_description = isset ( $json -> error_description ) ? sprintf ( __ ( 'Error Details: %s' , 'jetpack-connection' ), ( string ) $json -> error_description ) : '' ;
return new WP_Error ( ( string ) $json -> error , $error_description , $code );
}
if ( empty ( $json -> access_token ) || ! is_scalar ( $json -> access_token ) ) {
return new WP_Error ( 'access_token' , '' , $code );
}
if ( empty ( $json -> token_type ) || 'X_JETPACK' !== strtoupper ( $json -> token_type ) ) {
return new WP_Error ( 'token_type' , '' , $code );
}
if ( empty ( $json -> scope ) ) {
return new WP_Error ( 'scope' , 'No Scope' , $code );
}
// TODO: get rid of the error silencer.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ list ( $role , $hmac ) = explode ( ':' , $json -> scope );
if ( empty ( $role ) || empty ( $hmac ) ) {
return new WP_Error ( 'scope' , 'Malformed Scope' , $code );
}
if ( $this -> sign_role ( $role ) !== $json -> scope ) {
return new WP_Error ( 'scope' , 'Invalid Scope' , $code );
}
$cap = $roles -> translate_role_to_cap ( $role );
if ( ! $cap ) {
return new WP_Error ( 'scope' , 'No Cap' , $code );
}
if ( ! current_user_can ( $cap ) ) {
return new WP_Error ( 'scope' , 'current_user_cannot' , $code );
}
return ( string ) $json -> access_token ;
}
/**
* Enters a user token into the user_tokens option
*
* @ param int $user_id The user id .
* @ param string $token The user token .
* @ param bool $is_master_user Whether the user is the master user .
* @ return bool
*/
public function update_user_token ( $user_id , $token , $is_master_user ) {
// Not designed for concurrent updates.
$user_tokens = $this -> get_user_tokens ();
if ( ! is_array ( $user_tokens ) ) {
$user_tokens = array ();
}
$user_tokens [ $user_id ] = $token ;
if ( $is_master_user ) {
$master_user = $user_id ;
$options = compact ( 'user_tokens' , 'master_user' );
} else {
$options = compact ( 'user_tokens' );
}
return Jetpack_Options :: update_options ( $options );
}
/**
* Sign a user role with the master access token .
* If not specified , will default to the current user .
*
* @ access public
*
* @ param string $role User role .
* @ param int $user_id ID of the user .
* @ return string Signed user role .
*/
public function sign_role ( $role , $user_id = null ) {
if ( empty ( $user_id ) ) {
$user_id = ( int ) get_current_user_id ();
}
if ( ! $user_id ) {
return false ;
}
$token = $this -> get_access_token ();
if ( ! $token || is_wp_error ( $token ) ) {
return false ;
}
return $role . ':' . hash_hmac ( 'md5' , " { $role } | { $user_id } " , $token -> secret );
}
/**
* Increases the request timeout value to 30 seconds .
*
* @ return int Returns 30.
*/
public function return_30 () {
return 30 ;
}
/**
* Gets the requested token .
*
* Tokens are one of two types :
* 1. Blog Tokens : These are the " main " tokens . Each site typically has one Blog Token ,
* though some sites can have multiple " Special " Blog Tokens ( see below ) . These tokens
* are not associated with a user account . They represent the site ' s connection with
* the Jetpack servers .
* 2. User Tokens : These are " sub- " tokens . Each connected user account has one User Token .
*
* All tokens look like " { $token_key } . { $private } " . $token_key is a public ID for the
* token , and $private is a secret that should never be displayed anywhere or sent
* over the network ; it ' s used only for signing things .
*
* Blog Tokens can be " Normal " or " Special " .
* * Normal : The result of a normal connection flow . They look like
* " { $random_string_1 } . { $random_string_2 } "
* That is , $token_key and $private are both random strings .
* Sites only have one Normal Blog Token . Normal Tokens are found in either
* Jetpack_Options :: get_option ( 'blog_token' ) ( usual ) or the JETPACK_BLOG_TOKEN
* constant ( rare ) .
* * Special : A connection token for sites that have gone through an alternative
* connection flow . They look like :
* " ; { $special_id } { $special_version } ; { $wpcom_blog_id } ;. { $random_string } "
* That is , $private is a random string and $token_key has a special structure with
* lots of semicolons .
* Most sites have zero Special Blog Tokens . Special tokens are only found in the
* JETPACK_BLOG_TOKEN constant .
*
* In particular , note that Normal Blog Tokens never start with " ; " and that
* Special Blog Tokens always do .
*
* When searching for a matching Blog Tokens , Blog Tokens are examined in the following
* order :
* 1. Defined Special Blog Tokens ( via the JETPACK_BLOG_TOKEN constant )
* 2. Stored Normal Tokens ( via Jetpack_Options :: get_option ( 'blog_token' ))
* 3. Defined Normal Tokens ( via the JETPACK_BLOG_TOKEN constant )
*
* @ param int | false $user_id false : Return the Blog Token . int : Return that user ' s User Token .
* @ param string | false $token_key If provided , check that the token matches the provided input .
* @ param bool | true $suppress_errors If true , return a falsy value when the token isn 't found; When false, return a descriptive WP_Error when the token isn' t found .
*
* @ return object | false | WP_Error
*/
public function get_access_token ( $user_id = false , $token_key = false , $suppress_errors = true ) {
if ( $this -> is_locked () ) {
$this -> delete_all ();
return false ;
}
$possible_special_tokens = array ();
$possible_normal_tokens = array ();
$user_tokens = $this -> get_user_tokens ();
if ( $user_id ) {
if ( ! $user_tokens ) {
return $suppress_errors ? false : new WP_Error ( 'no_user_tokens' , __ ( 'No user tokens found' , 'jetpack-connection' ) );
}
if ( true === $user_id ) { // connection owner.
$user_id = Jetpack_Options :: get_option ( 'master_user' );
if ( ! $user_id ) {
return $suppress_errors ? false : new WP_Error ( 'empty_master_user_option' , __ ( 'No primary user defined' , 'jetpack-connection' ) );
}
}
if ( ! isset ( $user_tokens [ $user_id ] ) || ! $user_tokens [ $user_id ] ) {
// translators: %s is the user ID.
return $suppress_errors ? false : new WP_Error ( 'no_token_for_user' , sprintf ( __ ( 'No token for user %d' , 'jetpack-connection' ), $user_id ) );
}
$user_token_chunks = explode ( '.' , $user_tokens [ $user_id ] );
if ( empty ( $user_token_chunks [ 1 ] ) || empty ( $user_token_chunks [ 2 ] ) ) {
// translators: %s is the user ID.
return $suppress_errors ? false : new WP_Error ( 'token_malformed' , sprintf ( __ ( 'Token for user %d is malformed' , 'jetpack-connection' ), $user_id ) );
}
if ( $user_token_chunks [ 2 ] !== ( string ) $user_id ) {
// translators: %1$d is the ID of the requested user. %2$d is the user ID found in the token.
return $suppress_errors ? false : new WP_Error ( 'user_id_mismatch' , sprintf ( __ ( 'Requesting user_id %1$d does not match token user_id %2$d' , 'jetpack-connection' ), $user_id , $user_token_chunks [ 2 ] ) );
}
$possible_normal_tokens [] = " { $user_token_chunks [ 0 ] } . { $user_token_chunks [ 1 ] } " ;
} else {
$stored_blog_token = Jetpack_Options :: get_option ( 'blog_token' );
if ( $stored_blog_token ) {
$possible_normal_tokens [] = $stored_blog_token ;
}
$defined_tokens_string = Constants :: get_constant ( 'JETPACK_BLOG_TOKEN' );
if ( $defined_tokens_string ) {
$defined_tokens = explode ( ',' , $defined_tokens_string );
foreach ( $defined_tokens as $defined_token ) {
if ( ';' === $defined_token [ 0 ] ) {
$possible_special_tokens [] = $defined_token ;
} else {
$possible_normal_tokens [] = $defined_token ;
}
}
}
}
if ( self :: MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
$possible_tokens = $possible_normal_tokens ;
} else {
$possible_tokens = array_merge ( $possible_special_tokens , $possible_normal_tokens );
}
if ( ! $possible_tokens ) {
// If no user tokens were found, it would have failed earlier, so this is about blog token.
return $suppress_errors ? false : new WP_Error ( 'no_possible_tokens' , __ ( 'No blog token found' , 'jetpack-connection' ) );
}
$valid_token = false ;
if ( false === $token_key ) {
// Use first token.
$valid_token = $possible_tokens [ 0 ];
} elseif ( self :: MAGIC_NORMAL_TOKEN_KEY === $token_key ) {
// Use first normal token.
$valid_token = $possible_tokens [ 0 ]; // $possible_tokens only contains normal tokens because of earlier check.
} else {
// Use the token matching $token_key or false if none.
// Ensure we check the full key.
$token_check = rtrim ( $token_key , '.' ) . '.' ;
foreach ( $possible_tokens as $possible_token ) {
if ( hash_equals ( substr ( $possible_token , 0 , strlen ( $token_check ) ), $token_check ) ) {
$valid_token = $possible_token ;
break ;
}
}
}
if ( ! $valid_token ) {
if ( $user_id ) {
// translators: %d is the user ID.
return $suppress_errors ? false : new WP_Error ( 'no_valid_user_token' , sprintf ( __ ( 'Invalid token for user %d' , 'jetpack-connection' ), $user_id ) );
} else {
return $suppress_errors ? false : new WP_Error ( 'no_valid_blog_token' , __ ( 'Invalid blog token' , 'jetpack-connection' ) );
}
}
return ( object ) array (
'secret' => $valid_token ,
'external_user_id' => ( int ) $user_id ,
);
}
/**
* Updates the blog token to a new value .
*
* @ access public
*
* @ param string $token the new blog token value .
* @ return Boolean Whether updating the blog token was successful .
*/
public function update_blog_token ( $token ) {
return Jetpack_Options :: update_option ( 'blog_token' , $token );
}
/**
* Unlinks the current user from the linked WordPress . com user .
*
* @ access public
* @ static
*
* @ todo Refactor to properly load the XMLRPC client independently .
*
2023-06-05 11:21:22 +00:00
* @ param int $user_id The user identifier .
2022-11-24 13:40:35 +00:00
*
* @ return bool Whether the disconnection of the user was successful .
2022-07-28 18:42:13 +00:00
*/
2023-06-05 11:21:22 +00:00
public function disconnect_user ( $user_id ) {
2022-07-28 18:42:13 +00:00
$tokens = $this -> get_user_tokens ();
if ( ! $tokens ) {
return false ;
}
if ( ! isset ( $tokens [ $user_id ] ) ) {
return false ;
}
unset ( $tokens [ $user_id ] );
$this -> update_user_tokens ( $tokens );
return true ;
}
/**
* Returns an array of user_id ' s that have user tokens for communicating with wpcom .
* Able to select by specific capability .
*
* @ deprecated 1.30 . 0
* @ see Manager :: get_connected_users
*
* @ param string $capability The capability of the user .
* @ param int | null $limit How many connected users to get before returning .
* @ return array Array of WP_User objects if found .
*/
public function get_connected_users ( $capability = 'any' , $limit = null ) {
_deprecated_function ( __METHOD__ , '1.30.0' );
return ( new Manager ( 'jetpack' ) ) -> get_connected_users ( $capability , $limit );
}
/**
* 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 );
}
2023-10-22 22:21:06 +00:00
$normalized_request_string = implode (
2022-07-28 18:42:13 +00:00
" \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 );
}
2023-10-22 22:21:06 +00:00
return implode ( ' ' , $header_pieces );
2022-07-28 18:42:13 +00:00
}
/**
* Gets the list of user tokens
*
* @ since 1.30 . 0
*
* @ return bool | array An array of user tokens where keys are user IDs and values are the tokens . False if no user token is found .
*/
public function get_user_tokens () {
return Jetpack_Options :: get_option ( 'user_tokens' );
}
/**
* Updates the option that stores the user tokens
*
* @ since 1.30 . 0
*
* @ param array $tokens An array of user tokens where keys are user IDs and values are the tokens .
* @ return bool Was the option successfully updated ?
*
* @ todo add validate the input .
*/
public function update_user_tokens ( $tokens ) {
return Jetpack_Options :: update_option ( 'user_tokens' , $tokens );
}
/**
* Lock the tokens to the current site URL .
*
* @ param int $timespan How long the tokens should be locked , in seconds .
*
* @ return bool
*/
public function set_lock ( $timespan = HOUR_IN_SECONDS ) {
try {
$expires = ( new DateTime () ) -> add ( DateInterval :: createFromDateString ( ( int ) $timespan . ' seconds' ) );
} catch ( Exception $e ) {
return false ;
}
if ( false === $expires ) {
return false ;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return Jetpack_Options :: update_option ( 'token_lock' , $expires -> format ( static :: DATE_FORMAT_ATOM ) . '|||' . base64_encode ( Urls :: site_url () ) );
}
/**
* Remove the site lock from tokens .
*
* @ return bool
*/
public function remove_lock () {
Jetpack_Options :: delete_option ( 'token_lock' );
return true ;
}
/**
* Check if the domain is locked , remove the lock if needed .
* Possible scenarios :
* - lock expired , site URL matches the lock URL : remove the lock , return false .
* - lock not expired , site URL matches the lock URL : return false .
* - site URL does not match the lock URL ( expiration date is ignored ) : return true , do not remove the lock .
*
* @ return bool
*/
public function is_locked () {
$the_lock = Jetpack_Options :: get_option ( 'token_lock' );
if ( ! $the_lock ) {
// Not locked.
return false ;
}
$the_lock = explode ( '|||' , $the_lock , 2 );
if ( count ( $the_lock ) !== 2 ) {
// Something's wrong with the lock.
$this -> remove_lock ();
return false ;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$locked_site_url = base64_decode ( $the_lock [ 1 ] );
$expires = $the_lock [ 0 ];
$expiration_date = DateTime :: createFromFormat ( static :: DATE_FORMAT_ATOM , $expires );
if ( false === $expiration_date || ! $locked_site_url ) {
// Something's wrong with the lock.
$this -> remove_lock ();
return false ;
}
if ( Urls :: site_url () === $locked_site_url ) {
if ( new DateTime () > $expiration_date ) {
// Site lock expired.
// Site URL matches, removing the lock.
$this -> remove_lock ();
}
return false ;
}
// Site URL doesn't match.
return true ;
}
}