From f9e838733a4595b32693677c9736c0dcf3e49bc5 Mon Sep 17 00:00:00 2001 From: Derrick Koo Date: Mon, 13 May 2024 16:06:36 -0600 Subject: [PATCH] OAuth: use a custom table for transients (#3106) * feat: start new custom table for oauth transients data * fix: copy/paste error * fix(google-oauth): use custom table for transients * feat: cleanup old transients * fix: clean up on the fly too, and limit to deleting 1000 at a time max --------- Co-authored-by: Adam Cassis --- includes/class-newspack.php | 1 + includes/oauth/class-google-login.php | 9 +- includes/oauth/class-oauth-transients.php | 207 ++++++++++++++++++++++ includes/oauth/class-oauth.php | 15 +- 4 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 includes/oauth/class-oauth-transients.php diff --git a/includes/class-newspack.php b/includes/class-newspack.php index 06e38ffdc1..a811c960c2 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -101,6 +101,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/reader-revenue/my-account/class-woocommerce-my-account.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-revenue/class-reader-revenue-emails.php'; include_once NEWSPACK_ABSPATH . 'includes/oauth/class-oauth.php'; + include_once NEWSPACK_ABSPATH . 'includes/oauth/class-oauth-transients.php'; include_once NEWSPACK_ABSPATH . 'includes/oauth/class-google-oauth.php'; include_once NEWSPACK_ABSPATH . 'includes/oauth/class-google-services-connection.php'; include_once NEWSPACK_ABSPATH . 'includes/oauth/class-mailchimp-api.php'; diff --git a/includes/oauth/class-google-login.php b/includes/oauth/class-google-login.php index 326fdd5423..56a609e5b1 100644 --- a/includes/oauth/class-google-login.php +++ b/includes/oauth/class-google-login.php @@ -144,10 +144,9 @@ public static function oauth_callback() { Logger::log( 'Got user email from Google: ' . $user_email ); // Associate the email address with the a unique ID for later retrieval. - $transient_expiration_time = 60 * 5; // 5 minutes. - $has_set_transient = set_transient( self::EMAIL_TRANSIENT_PREFIX . OAuth::get_unique_id(), $user_email, $transient_expiration_time ); + $set_transient_result = OAuth_Transients::set( OAuth::get_unique_id(), 'email', $user_email ); // If transient setting failed, the email address will not be available for the registration endpoint. - if ( ! $has_set_transient ) { + if ( $set_transient_result === false ) { self::handle_error( __( 'Failed setting transient.', 'newspack-plugin' ) ); \wp_die( \esc_html__( 'Authentication failed.', 'newspack-plugin' ) ); } @@ -178,8 +177,8 @@ private static function handle_error( $message ) { public static function api_google_login_register( $request ) { $uid = OAuth::get_unique_id(); // Retrieve the email address associated with the unique ID when the user was authenticated. - $email = get_transient( self::EMAIL_TRANSIENT_PREFIX . $uid ); - delete_transient( self::EMAIL_TRANSIENT_PREFIX . $uid ); // Burn after reading. + $email = OAuth_Transients::get( $uid, 'email' ); + OAuth_Transients::delete( $uid, 'email' ); $metadata = []; if ( $request->get_param( 'metadata' ) ) { try { diff --git a/includes/oauth/class-oauth-transients.php b/includes/oauth/class-oauth-transients.php new file mode 100644 index 0000000000..93cdec1a97 --- /dev/null +++ b/includes/oauth/class-oauth-transients.php @@ -0,0 +1,207 @@ +prefix . self::TABLE_NAME; + } + + /** + * Checks if the custom table has been created and is up-to-date. + * If not, run the create_custom_table method. + * See: https://codex.wordpress.org/Creating_Tables_with_Plugins + */ + public static function check_update_version() { + $current_version = \get_option( self::TABLE_VERSION_OPTION, false ); + + if ( self::TABLE_VERSION !== $current_version ) { + self::create_custom_table(); + \update_option( self::TABLE_VERSION_OPTION, self::TABLE_VERSION ); + } + } + + /** + * Create a custom DB table to store transient data needed for OAuth. + * Avoids the use of slow post meta for query sorting purposes. + * Only create the table if it doesn't already exist. + */ + public static function create_custom_table() { + global $wpdb; + $table_name = self::get_table_name(); + + if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) != $table_name ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $charset_collate = $wpdb->get_charset_collate(); + $sql = "CREATE TABLE $table_name ( + -- Reader's unique ID. + id varchar(100) NOT NULL, + -- Scope of the data. + scope varchar(30) NOT NULL, + -- Value of the data. + value varchar(100) NOT NULL, + -- Timestamp when data was created. + created_at datetime NOT NULL, + PRIMARY KEY (id, scope), + KEY (scope) + ) $charset_collate;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + } + + /** + * Get a value from the database. + * + * @param string $id The reader's unique ID. + * @param string $scope The scope of the data to get. + * @param string $field_to_get The column to get. Defaults to 'value'. + * @param boolean $cleanup If true, clean up old transients while getting. + * + * @return mixed The value of the data, or false if not found. + */ + public static function get( $id, $scope, $field_to_get = 'value', $cleanup = true ) { + global $wpdb; + $table_name = self::get_table_name(); + + $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + 'SELECT %1$s FROM %2$s WHERE id = "%3$s" AND scope = "%4$s"', // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder + $field_to_get, + $table_name, + $id, + $scope + ) + ); + + // Prune old transients. + if ( $cleanup ) { + self::cleanup(); + } + + return $value ?? false; + } + + /** + * Delete a row from the database. + * + * @param string $id The reader's unique ID. + * @param string $scope The scope of the data to get. + * + * @return boolean True if the row was deleted. + */ + public static function delete( $id, $scope ) { + global $wpdb; + $table_name = self::get_table_name(); + + $result = $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $table_name, + [ + 'id' => $id, + 'scope' => $scope, + ], + [ '%s', '%s' ] + ); + + return boolval( $result ); + } + + /** + * Set a value in the database. + * + * @param string $id The reader's unique ID. + * @param string $scope The scope of the data to set. + * @param string $value The value of the data to set. + * + * @return mixed The value if it was set, false otherwise. + */ + public static function set( $id, $scope, $value ) { + global $wpdb; + + $existing = self::get( $id, $scope ); + if ( $existing ) { + return $existing; + } + + $table_name = self::get_table_name(); + $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $table_name, + [ + 'id' => $id, + 'scope' => $scope, + 'value' => $value, + 'created_at' => \current_time( 'mysql', true ), // GMT time. + ], + [ + '%s', + '%s', + '%s', + '%s', + ] + ); + + if ( $result === false ) { + return false; + } + + return $value; + } + + /** + * Cleanup old transients. Limit to max 1000 at a time, just in case. + */ + public static function cleanup() { + global $wpdb; + $table_name = self::get_table_name(); + $wpdb->query( "DELETE FROM $table_name WHERE created_at < now() - interval 30 MINUTE LIMIT 1000" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} + +OAuth_Transients::init(); diff --git a/includes/oauth/class-oauth.php b/includes/oauth/class-oauth.php index 937df3dbe9..6ee8794917 100644 --- a/includes/oauth/class-oauth.php +++ b/includes/oauth/class-oauth.php @@ -13,7 +13,7 @@ * Main class. */ class OAuth { - const CSRF_TOKEN_TRANSIENT_NAME_BASE = '_newspack_google_oauth_csrf_'; + const CSRF_TOKEN_TRANSIENT_SCOPE_PREFIX = 'csrf_'; /** * Get API key for proxies. @@ -46,10 +46,9 @@ public static function get_unique_id() { * @return string CSRF token. */ public static function generate_csrf_token( $namespace ) { - $csrf_token = sha1( openssl_random_pseudo_bytes( 1024 ) ); - $transient_name = self::CSRF_TOKEN_TRANSIENT_NAME_BASE . $namespace . self::get_unique_id(); - set_transient( $transient_name, $csrf_token, 5 * MINUTE_IN_SECONDS ); - return $csrf_token; + $csrf_token = sha1( openssl_random_pseudo_bytes( 1024 ) ); + $transient_scope = self::CSRF_TOKEN_TRANSIENT_SCOPE_PREFIX . $namespace; + return OAuth_Transients::set( self::get_unique_id(), $transient_scope, $csrf_token ); } /** @@ -59,8 +58,10 @@ public static function generate_csrf_token( $namespace ) { * @return string CSRF token. */ public static function retrieve_csrf_token( $namespace ) { - $csrf_token_transient_name = self::CSRF_TOKEN_TRANSIENT_NAME_BASE . $namespace . self::get_unique_id(); - return get_transient( $csrf_token_transient_name ); + $transient_scope = self::CSRF_TOKEN_TRANSIENT_SCOPE_PREFIX . $namespace; + $value = OAuth_Transients::get( self::get_unique_id(), $transient_scope ); + OAuth_Transients::delete( self::get_unique_id(), $transient_scope ); + return $value; } /**