Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth: use a custom table for transients #3106

Merged
merged 6 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions includes/class-newspack.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 4 additions & 5 deletions includes/oauth/class-google-login.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}
Expand Down Expand Up @@ -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 {
Expand Down
207 changes: 207 additions & 0 deletions includes/oauth/class-oauth-transients.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php
/**
* Custom table for temporary data required by OAuth flows.
*
* @package Newspack
*/

namespace Newspack;

defined( 'ABSPATH' ) || exit;

/**
* Main class.
*/
class OAuth_Transients {
const TABLE_NAME = 'newspack_oauth_transients';
const TABLE_VERSION = '1.0';
const TABLE_VERSION_OPTION = '_newspack_oauth_transients_version';
const CRON_HOOK = 'np_oauth_transients_cleanup';

/**
* Initialize hooks.
*/
public static function init() {
register_activation_hook( NEWSPACK_PLUGIN_FILE, [ __CLASS__, 'create_custom_table' ] );
add_action( 'init', [ __CLASS__, 'check_update_version' ] );
add_action( 'init', [ __CLASS__, 'cron_init' ] );
add_action( self::CRON_HOOK, [ __CLASS__, 'cleanup' ] );
}

/**
* Schedule cron job to prune unused transients. If the OAuth process is interrupted,
* a transient might never be deleted.
*/
public static function cron_init() {
\register_deactivation_hook( NEWSPACK_PLUGIN_FILE, [ __CLASS__, 'cron_deactivate' ] );
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
\wp_schedule_event( time(), 'weekly', self::CRON_HOOK );
}
}

/**
* Deactivate the cron job.
*/
public static function cron_deactivate() {
\wp_clear_scheduled_hook( self::CRON_HOOK );
}

/**
* Get custom table name.
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->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();
15 changes: 8 additions & 7 deletions includes/oauth/class-oauth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
}

/**
Expand All @@ -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;
}

/**
Expand Down