connections = $connections; $this->sites = $sites; $this->credentials = $credentials; $this->cache = $cache; $this->health = $health; } /** * {@inheritDoc} * * @return array */ protected function actions() { return array( 'add_oauth' => 'add_oauth', 'reconnect' => 'reconnect', 'oauth_callback' => 'oauth_callback', 'add_key' => 'add_key', 'delete_connection' => 'delete_connection', 'check_connection' => 'check_connection', ); } /** * The redirect URI to register on the OAuth client. * * Google accepts query strings in redirect URIs as long as they match exactly. * * @return string */ public static function redirect_uri() { return admin_url( 'admin-post.php?action=turtlereport_oauth_callback' ); } /** * Saves a new OAuth client and sends the admin to Google. * * @return void */ public function add_oauth() { $this->guard( 'add_oauth' ); $client_id = $this->text( 'client_id' ); $secret = $this->text( 'client_secret' ); if ( ! preg_match( '/^[\w.-]+\.apps\.googleusercontent\.com$/', $client_id ) ) { // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Checks the format of a Google OAuth client ID; nothing is loaded from this domain. $this->finish( ConnectionsPage::url(), 'error', __( 'That doesn\'t look like a Client ID. It ends with .apps.googleusercontent.com.', 'turtlereport' ) ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Tells the admin what a Client ID looks like; nothing is loaded from this domain. } if ( '' === $secret ) { $this->finish( ConnectionsPage::url(), 'error', __( 'Paste the client secret too. Google shows it once, when the client is created; you can add a new secret on the client\'s page.', 'turtlereport' ) ); } $connection = new Connection(); $connection->type = Connection::TYPE_OAUTH; $connection->label = $this->text( 'label' ); $connection->client_id = $client_id; $connection->want_business_profile = ! empty( $_POST['business_profile'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in guard(). $connection = $this->connections->save( $connection ); $this->connections->set_secret( $connection->id, ConnectionRepository::SECRET_CLIENT_SECRET, $secret ); self::start_oauth( $connection ); } /** * Signs in again for an existing OAuth connection. * * @return void */ public function reconnect() { $this->guard( 'reconnect' ); $connection = $this->connections->find( $this->text( 'connection' ) ); if ( ! $connection || Connection::TYPE_OAUTH !== $connection->type ) { $this->finish( ConnectionsPage::url(), 'error', __( 'Connection not found.', 'turtlereport' ) ); } $secret = $this->text( 'client_secret' ); if ( '' !== $secret ) { $this->connections->set_secret( $connection->id, ConnectionRepository::SECRET_CLIENT_SECRET, $secret ); } $connection->want_business_profile = ! empty( $_POST['business_profile'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in guard(). $this->connections->save( $connection ); self::start_oauth( $connection ); } /** * Handles Google's redirect back after consent. * * CSRF protection here is the one-time "state" value, tied to the * current user, because Google can't return a WordPress nonce. * * @return void */ public function oauth_callback() { if ( ! current_user_can( Capabilities::MANAGE ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'turtlereport' ), '', array( 'response' => 403 ) ); } $key = 'turtlereport_oauth_' . get_current_user_id(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Verified with the OAuth state below. $state = isset( $_GET['state'] ) && is_string( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $error = isset( $_GET['error'] ) && is_string( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : ''; $code = isset( $_GET['code'] ) && is_string( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:enable $pending = self::consume_pending( $key, $state ); if ( null === $pending ) { $this->finish( ConnectionsPage::url(), 'error', __( 'That Google sign-in link expired or was already used. Start the connection again.', 'turtlereport' ) ); } // Where the flow began. The site editor sends admins back to the form they left, with what they had typed. $back = isset( $pending['return'] ) && is_string( $pending['return'] ) && '' !== $pending['return'] ? $pending['return'] : ConnectionsPage::url(); $connection = $this->connections->find( (string) $pending['connection'] ); if ( ! $connection ) { $this->finish( $back, 'error', __( 'Connection not found.', 'turtlereport' ) ); } if ( '' !== $error ) { $this->finish( $back, 'error', 'access_denied' === $error ? __( 'Google access wasn\'t granted. If Google said the app is blocked or you\'re not a test user, add your account under Google Auth Platform → Audience → Test users, or publish the app.', 'turtlereport' ) /* translators: %s: error from Google. */ : sprintf( __( 'Google sign-in failed: %s', 'turtlereport' ), $error ) ); } try { $tokens = OAuthClient::exchange_code( $connection->client_id, $this->connections->secret( $connection->id, ConnectionRepository::SECRET_CLIENT_SECRET ), $code, (string) $pending['verifier'], self::redirect_uri() ); } catch ( ApiException $e ) { $this->finish( $back, 'error', $e->getMessage() ); } if ( empty( $tokens['refresh_token'] ) ) { $this->finish( $back, 'error', __( 'Google didn\'t return a long-term token. Remove this app at myaccount.google.com/permissions, then reconnect.', 'turtlereport' ) ); } $granted = isset( $tokens['scope'] ) ? array_values( array_filter( explode( ' ', (string) $tokens['scope'] ) ) ) : array(); if ( ! in_array( Scopes::SEARCH_CONSOLE, $granted, true ) && ! in_array( Scopes::ANALYTICS, $granted, true ) ) { $this->finish( $back, 'error', __( 'Neither Search Console nor Analytics permission was ticked on Google\'s consent screen. Reconnect and tick both boxes.', 'turtlereport' ) ); } $this->connections->set_secret( $connection->id, ConnectionRepository::SECRET_REFRESH_TOKEN, (string) $tokens['refresh_token'] ); $connection->scopes = $granted; $connection->status = Connection::STATUS_CONNECTED; $connection->account_email = isset( $tokens['id_token'] ) ? OAuthClient::email_from_id_token( $tokens['id_token'] ) : $connection->account_email; $this->connections->save( $connection ); $this->forget_connection_cache( $connection ); $this->health->mark_ok( $connection ); $this->health->diagnose( $connection ); $missing = array(); if ( ! in_array( Scopes::SEARCH_CONSOLE, $granted, true ) ) { $missing[] = 'Search Console'; } if ( ! in_array( Scopes::ANALYTICS, $granted, true ) ) { $missing[] = 'Google Analytics'; } if ( $connection->want_business_profile && ! in_array( Scopes::BUSINESS_PROFILE, $granted, true ) ) { $missing[] = 'Business Profile'; } if ( $missing ) { /* translators: %s: comma-separated list of Google products. */ Notices::add( 'warning', sprintf( __( 'Connected, but without permission for: %s. Reconnect and tick those boxes if you need them.', 'turtlereport' ), implode( ', ', $missing ) ) ); } else { /* translators: %s: Google account email. */ Notices::add( 'success', sprintf( __( 'Connected as %s.', 'turtlereport' ), '' !== $connection->account_email ? $connection->account_email : $connection->display_name() ) ); } $this->finish( isset( $pending['return'] ) && '' !== $pending['return'] ? $back : $this->next_screen( $connection ) ); } /** * Adds a service-account connection, or replaces an existing key. * * @return void */ public function add_key() { $this->guard( 'add_key' ); $json = $this->posted_key(); try { $key = ServiceAccountCredentials::parse_key( $json ); } catch ( \InvalidArgumentException $e ) { $this->finish( ConnectionsPage::url(), 'error', $e->getMessage() ); } $existing = $this->connections->find( $this->text( 'connection' ) ); $connection = $existing && Connection::TYPE_SERVICE_ACCOUNT === $existing->type ? $existing : new Connection(); // Prove the key works before saving it. try { ( new ServiceAccountCredentials( 'test', $key ) )->access_token( true ); } catch ( ApiException $e ) { $this->finish( ConnectionsPage::url(), 'error', $e->getMessage() ); } $connection->type = Connection::TYPE_SERVICE_ACCOUNT; $connection->label = '' !== $this->text( 'label' ) ? $this->text( 'label' ) : $connection->label; $connection->account_email = $key['client_email']; $connection->scopes = Scopes::for_service_account(); $connection->status = Connection::STATUS_CONNECTED; $connection = $this->connections->save( $connection ); $this->connections->set_secret( $connection->id, ConnectionRepository::SECRET_KEY, (string) wp_json_encode( $key ) ); delete_transient( 'turtlereport_at_' . md5( 'sa:test:' . $key['private_key_id'] ) ); $this->forget_connection_cache( $connection ); $this->health->mark_ok( $connection ); $this->health->diagnose( $connection ); Notices::add( 'success', /* translators: %s: service-account email address. */ sprintf( __( 'Key saved. Now add %s as a user in Search Console (Restricted) and GA4 (Viewer) for each website you want to report on.', 'turtlereport' ), $key['client_email'] ) ); $this->finish( $this->next_screen( $connection ) ); } /** * Deletes a connection that no site uses. * * @return void */ public function delete_connection() { $this->guard( 'delete_connection' ); $connection = $this->connections->find( $this->text( 'connection' ) ); if ( ! $connection ) { $this->finish( ConnectionsPage::url(), 'error', __( 'Connection not found.', 'turtlereport' ) ); } $used = $this->sites->using_connection( $connection->id ); if ( $used ) { $this->finish( ConnectionsPage::url(), 'error', /* translators: %d: number of sites. */ sprintf( _n( 'This connection is used by %d site. Move that site to another connection first.', 'This connection is used by %d sites. Move those sites to another connection first.', count( $used ), 'turtlereport' ), count( $used ) ) ); } if ( $connection->is_connected() && ! $this->shares_grant( $connection ) ) { try { $this->credentials->for_connection( $connection )->revoke(); } catch ( ApiException $e ) { // Secrets unreadable or already revoked: deleting locally is still correct. unset( $e ); } } $this->forget_connection_cache( $connection ); $this->connections->delete( $connection->id ); $this->finish( ConnectionsPage::url(), 'success', __( 'Connection deleted.', 'turtlereport' ) ); } /** * Tests a connection now and reports the result. * * @return void */ public function check_connection() { $this->guard( 'check_connection' ); $connection = $this->connections->find( $this->text( 'connection' ) ); if ( ! $connection ) { $this->finish( ConnectionsPage::url(), 'error', __( 'Connection not found.', 'turtlereport' ) ); } $result = $this->health->diagnose( $connection ); $failed = array_filter( $result['checks'], static function ( $c ) { return false === $c['ok']; } ); $back = ConnectionsPage::url() . '#tly-conn-' . $connection->id; if ( ! $failed ) { /* translators: %s: connection name. */ $this->finish( $back, 'success', sprintf( __( '"%s" passed every test. Details are under the connection.', 'turtlereport' ), $connection->display_name() ) ); } /* translators: 1: connection name, 2: number of problems. */ $this->finish( $back, 'warning', sprintf( _n( '"%1$s" has %2$d problem. See the test results under it for how to fix it.', '"%1$s" has %2$d problems. See the test results under it for how to fix them.', count( $failed ), 'turtlereport' ), $connection->display_name(), count( $failed ) ) ); } /** * Stores PKCE and state values, then redirects to Google's consent screen. * * Shared with the site editor, which starts the same flow for a new Google * account and asks to be returned to the half-filled form afterwards. * * @param Connection $connection Connection being authorised. * @param string $return Admin URL to come back to; empty uses the usual screens. * @return void */ public static function start_oauth( Connection $connection, $return = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.returnFound -- Preserve the released public parameter name for PHP named callers. $state = wp_generate_password( 32, false ); $verifier = wp_generate_password( 64, false ); \TurtleReport\Support\TransientStore::set( 'turtlereport_oauth_' . get_current_user_id(), array( 'state' => $state, 'verifier' => $verifier, 'connection' => $connection->id, 'return' => (string) $return, ), 15 * MINUTE_IN_SECONDS ); $url = OAuthClient::authorize_url( $connection->client_id, self::redirect_uri(), Scopes::for_oauth( $connection->want_business_profile ), $state, $verifier, '' !== $return ); add_filter( 'allowed_redirect_hosts', static function ( $hosts ) { $hosts[] = 'accounts.google.com'; return $hosts; } ); wp_safe_redirect( $url ); exit; } /** * Reads the uploaded or pasted key file. * * @return string */ private function posted_key() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Checked in guard(). if ( isset( $_FILES['key_file']['tmp_name'], $_FILES['key_file']['size'] ) && is_string( $_FILES['key_file']['tmp_name'] ) && is_numeric( $_FILES['key_file']['size'] ) && is_uploaded_file( sanitize_text_field( $_FILES['key_file']['tmp_name'] ) ) ) { if ( (int) $_FILES['key_file']['size'] > self::MAX_KEY_BYTES ) { return ''; } // The file is read once and never stored on disk; its contents are validated by parse_key(). // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local temporary upload, not a URL. $contents = file_get_contents( sanitize_text_field( $_FILES['key_file']['tmp_name'] ), false, null, 0, self::MAX_KEY_BYTES + 1 ); return false === $contents || strlen( $contents ) > self::MAX_KEY_BYTES ? '' : $contents; } if ( isset( $_POST['key_json'] ) && is_string( $_POST['key_json'] ) ) { // JSON can't go through sanitize_text_field() without breaking the private key; parse_key() validates every field. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $json = trim( (string) wp_unslash( $_POST['key_json'] ) ); return strlen( $json ) > self::MAX_KEY_BYTES ? '' : $json; } // phpcs:enable return ''; } /** * Validates and consumes a current-user OAuth flow under an owned claim. * * @param string $key Current user's pending-flow transient. * @param string $state Returned state. * @return array|null Null for missing, malformed, mismatched or consumed state. */ private static function consume_pending( $key, $state ) { $valid = static function ( $pending ) use ( $state ) { if ( ! is_array( $pending ) || '' === $state ) { return false; } foreach ( array( 'state', 'verifier', 'connection' ) as $field ) { if ( ! isset( $pending[ $field ] ) || ! is_string( $pending[ $field ] ) || '' === $pending[ $field ] ) { return false; } } return hash_equals( $pending['state'], $state ); }; if ( ! $valid( get_transient( $key ) ) ) { return null; } $lock = OptionLock::acquire( 'turtlereport_oauth_claim_' . get_current_user_id(), 30 ); if ( null === $lock ) { return null; } try { if ( wp_using_ext_object_cache() ) { $pending = wp_cache_get( $key, 'transient', true ); } else { wp_cache_delete( '_transient_' . $key, 'options' ); $pending = get_transient( $key ); } if ( ! $valid( $pending ) || ! delete_transient( $key ) ) { return null; } return $pending; } finally { $lock->release(); } } /** * Whether another connection holds the same Google grant (same client and account). * * Revoking would sign that one out too, so revocation is skipped. * * @param Connection $connection Connection being deleted. * @return bool */ private function shares_grant( Connection $connection ) { if ( Connection::TYPE_OAUTH !== $connection->type ) { return false; } foreach ( $this->connections->all() as $other ) { if ( $other->id !== $connection->id && $other->client_id === $connection->client_id && $other->account_email === $connection->account_email ) { return true; } } return false; } /** * Clears cached data for a connection and the sites using it. * * @param Connection $connection Connection. * @return void */ private function forget_connection_cache( Connection $connection ) { $this->cache->flush( ServicesFactory::connection_group( $connection->id ) ); foreach ( $this->sites->using_connection( $connection->id ) as $site ) { $this->cache->flush( ServicesFactory::site_group( $site->id ) ); } } /** * Where to go after connecting: add a site if none uses this connection yet. * * @param Connection $connection Connection. * @return string */ private function next_screen( Connection $connection ) { if ( $this->sites->using_connection( $connection->id ) ) { return ConnectionsPage::url(); } return SitesPage::url( array( 'action' => 'new', 'connection' => $connection->id, ) ); } }