array( 'login' => 'https://discord.com/api/oauth2/authorize', 'token' => 'https://discord.com/api/oauth2/token', 'user' => 'https://discord.com/api/users/@me', 'revoke' => 'https://discord.com/api/oauth2/token/revoke', 'scope' => 'identify email' ), 'twitch' => array( 'login' => 'https://id.twitch.tv/oauth2/authorize', 'token' => 'https://id.twitch.tv/oauth2/token', 'user' => 'https://api.twitch.tv/helix/users', 'revoke' => 'https://id.twitch.tv/oauth2/revoke', 'scope' => 'user:read:email' ), 'google' => array( 'login' => 'https://accounts.google.com/o/oauth2/auth', 'token' => 'https://oauth2.googleapis.com/token', 'user' => 'https://www.googleapis.com/oauth2/v1/userinfo', 'revoke' => 'https://oauth2.googleapis.com/revoke', 'scope' => 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile' ), 'patreon' => array( 'login' => 'https://www.patreon.com/oauth2/authorize', 'token' => 'https://www.patreon.com/api/oauth2/token', 'user' => 'https://www.patreon.com/api/oauth2/v2/identity', 'revoke' => '', 'scope' => urlencode( 'identity identity[email]' ) ), 'subscribestar' => array( 'login' => 'https://www.subscribestar.com/oauth2/authorize', 'token' => 'https://www.subscribestar.com/oauth2/token', 'api' => 'https://www.subscribestar.com/api/graphql/v1', 'revoke' => '', 'scope' => 'subscriber.read+subscriber.payments.read+user.read+user.email.read' ) ) ); // ============================================================================= // PROCESS // ============================================================================= /** * Handle OAuth 2.0 requests to get campaign tiers * * @since 5.15.0 * @since 5.19.0 - Refactored */ function fictioneer_oauth2_get_campaign_tiers() { // Abort if not on the /oauth2 route... if ( is_null( get_query_var( FICTIONEER_OAUTH_ENDPOINT, null ) ) ) { return; } // Setup $cookie = fictioneer_oauth2_get_cookie(); $state = sanitize_key( $_GET['state'] ?? '' ); $code = sanitize_text_field( $_GET['code'] ?? '' ); // Do not use sanitize_key() // Validate! if ( ! current_user_can( 'manage_options' ) || ( $cookie['action'] ?? 0 ) !== 'fictioneer_connection_get_patreon_tiers' || ( $cookie['state'] ?? 0 ) !== $state ) { return; } // Delete cookie fictioneer_oauth2_delete_cookie(); // Get access token $token_response = fictioneer_oauth2_get_token( FCN_OAUTH2_API_ENDPOINTS['patreon']['token'], array( 'grant_type' => 'authorization_code', 'client_id' => get_option( 'fictioneer_patreon_client_id' ), 'client_secret' => get_option( 'fictioneer_patreon_client_secret' ), 'redirect_uri' => get_site_url( null, FICTIONEER_OAUTH_ENDPOINT ), 'code' => $code, 'scope' => urlencode( 'campaigns' ) ) ); // Token successfully retrieved? if ( is_wp_error( $token_response ) ) { fictioneer_oauth_die( $token_response->get_error_message() ); } // Extract token $access_token = isset( $token_response->access_token ) ? $token_response->access_token : null; if ( ! $access_token ) { fictioneer_oauth2_terminate( admin_url( 'admin.php?page=fictioneer_connections' ), array( 'failure' => 'fictioneer-patreon-token-missing' ) ); } // Build params $params = '?fields' . urlencode( '[campaign]' ) . '=created_at'; $params .= '&fields' . urlencode( '[tier]' ) . '=title,amount_cents,published,description'; $params .= '&include=tiers'; // Retrieve campaign data from Patreon $campaign_response = wp_remote_get( 'https://www.patreon.com/api/oauth2/v2/campaigns' . $params, array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token ) ) ); // Request successful? if ( is_wp_error( $campaign_response ) ) { fictioneer_oauth_die( $campaign_response->get_error_message() ); } else { $body = json_decode( wp_remote_retrieve_body( $campaign_response ) ); } // Data successfully retrieved? if ( empty( $body ) || json_last_error() !== JSON_ERROR_NONE ) { fictioneer_oauth_die( wp_remote_retrieve_body( $campaign_response ) ); } // Data? if ( ! isset( $body->data ) ) { fictioneer_oauth_die( 'Data node not found.' ); } // Includes? if ( ! isset( $body->included ) ) { fictioneer_oauth_die( 'Includes node not found.' ); } // Extract tiers $tiers = []; foreach ( $body->included as $item ) { if ( $item->type !== 'tier' || ! isset( $item->attributes ) ) { continue; } $tiers[ $item->id ] = array( 'id' => absint( $item->id ), 'title' => sanitize_text_field( $item->attributes->title ?? '' ), 'description' => wp_kses_post( $item->attributes->description ?? '' ), 'amount_cents' => absint( $item->attributes->amount_cents ?? 0 ), 'published' => filter_var( $item->attributes->published ?? 0, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ), ); } // Save as autoload option update_option( 'fictioneer_connection_patreon_tiers', $tiers ); // Finish wp_safe_redirect( add_query_arg( array( 'success' => 'fictioneer-patreon-tiers-pulled' ), admin_url( 'admin.php?page=fictioneer_connections' ) ) ); exit; } add_action( 'template_redirect', 'fictioneer_oauth2_get_campaign_tiers', 5 ); /** * Process any OAuth 2.0 request * * @since 5.19.0 * @link https://dev.twitch.tv/docs/authentication * @link https://discord.com/developers/docs/topics/oauth2 * @link https://docs.patreon.com/#step-1-registering-your-client * @link https://developers.google.com/identity/protocols/oauth2 */ function fictioneer_oauth2_process() { // Abort if not on the /oauth2 route... if ( is_null( get_query_var( FICTIONEER_OAUTH_ENDPOINT, null ) ) ) { return; } // Setup $cookie = fictioneer_oauth2_get_cookie(); $action = sanitize_key( $_GET['action'] ?? '' ); $state = sanitize_key( $_GET['state'] ?? '' ); $code = sanitize_text_field( $_GET['code'] ?? '' ); // Do not use sanitize_key() $channel = sanitize_key( $_GET['channel'] ?? '' ); $merge = ( $_GET['merge'] ?? $cookie['merge'] ?? 0 ) ? 1 : 0; $anchor = sanitize_key( $_GET['anchor'] ?? $cookie['anchor'] ?? '' ); $return_url = wp_validate_redirect( $_GET['return_url'] ?? $cookie['return_url'] ?? home_url(), home_url() ); $result = ''; // Verify! if ( ! $state && ! wp_verify_nonce( $_GET['oauth_nonce'] ?? '', 'authenticate' ) ) { fictioneer_oauth2_terminate( $return_url, array( 'failure' => 'oauth_invalid_nonce' ), $anchor ); } // Merge? if ( ( ! is_user_logged_in() && $merge ) || ( is_user_logged_in() && ! $merge ) ) { fictioneer_oauth2_terminate( $return_url, array( 'failure' => 'oauth_invalid_merge_request' ), $anchor ); } // Login: Get code! if ( $action === 'login' && ! $state ) { fictioneer_oauth2_get_code( array( 'channel' => $channel, 'anchor' => $anchor, 'return_url' => $return_url, 'merge' => $merge, 'merge_id' => get_current_user_id() ) ); } // Cookie? if ( ! $cookie || ! ( $cookie['state'] ?? 0 ) || ! ( $cookie['channel'] ?? 0 ) || ! ( $cookie['return_url'] ?? 0 ) ) { fictioneer_oauth2_terminate( $cookie['return_url'], array( 'failure' => 'oauth_invalid_cookie' ), $anchor ); } // State? if ( $cookie['state'] !== $state ) { fictioneer_oauth2_terminate( $cookie['return_url'], array( 'failure' => 'oauth_invalid_state' ), $anchor ); } // Code? if ( ! $code ) { fictioneer_oauth2_terminate( $cookie['return_url'], array( 'failure' => 'oauth_invalid_code' ), $anchor ); } // Code: Get access token! $token_response = fictioneer_oauth2_get_token( FCN_OAUTH2_API_ENDPOINTS[ $cookie['channel'] ]['token'], array( 'grant_type' => 'authorization_code', 'client_id' => get_option( "fictioneer_{$cookie['channel']}_client_id" ), 'client_secret' => get_option( "fictioneer_{$cookie['channel']}_client_secret" ), 'redirect_uri' => get_site_url( null, FICTIONEER_OAUTH_ENDPOINT ), 'code' => $code, 'scope' => FCN_OAUTH2_API_ENDPOINTS[ $cookie['channel'] ]['scope'] ) ); // Error? if ( is_wp_error( $token_response ) ) { fictioneer_oauth2_terminate( $cookie['return_url'], array( 'failure' => $token_response->get_error_code() ), $anchor ); } // Token? $access_token = isset( $token_response->access_token ) ? $token_response->access_token : null; if ( ! $access_token ) { fictioneer_oauth2_terminate( $cookie['return_url'], array( 'failure' => 'oauth_token_missing' ), $anchor ); } // Delegate to respective channel switch ( $cookie['channel'] ) { case 'discord': $result = fictioneer_oauth2_discord( $token_response, $cookie ); break; case 'patreon': $result = fictioneer_oauth2_patreon( $token_response, $cookie ); break; case 'google': $result = fictioneer_oauth2_google( $token_response, $cookie ); break; case 'twitch': $result = fictioneer_oauth2_twitch( $token_response, $cookie ); break; // case 'subscribestar': // $result = fictioneer_oauth2_subscribestar( $token_response, $cookie ); // break; default: $result = 'oauth_invalid_channel'; } // Error: Any if ( ! in_array( $result, ['merged_user', 'new_user', 'known_user'] ) ) { $return_url = add_query_arg( 'failure', $result, $return_url ); } // Success: Merged if ( $result === 'merged_user' ) { $return_url = add_query_arg( 'success', 'oauth_merged_' . $cookie['channel'], $return_url ); } // Success: New if ( $result === 'new_user' ) { $return_url = add_query_arg( 'success', 'oauth_new', $return_url ); } // Success: Known if ( $result === 'known_user' ) { $return_url = add_query_arg( 'success', 'oauth_known', $return_url ); } // Finish fictioneer_oauth2_delete_cookie(); wp_safe_redirect( $return_url . ( $anchor ? '#' . $anchor : '' ) ); exit; } add_action( 'template_redirect', 'fictioneer_oauth2_process' ); /** * Log in or register user * * @since 5.19.0 * * @param array $user_data Array of user data. * @param array $cookie The decrypted cookie data. */ function fictioneer_oauth2_make_user( $user_data, $cookie ) { // Setup $channel = $user_data['channel']; $meta_key = "fictioneer_{$channel}_id_hash"; $channel_id = 'fcn_' . hash( 'sha256', sanitize_key( $user_data['uid'] ) ); $username = sanitize_user( $user_data['username'] ); $nickname = sanitize_user( $user_data['nickname'] ); $email = sanitize_email( $user_data['email'] ); $avatar = fictioneer_url_exists( $user_data['avatar'] ) ? $user_data['avatar'] : null; $merge_id = absint( $cookie['merge_id'] ?? 0 ); $new = false; $merged = false; // Randomize username? if ( get_option( 'fictioneer_randomize_oauth_usernames' ) ) { $username = fictioneer_get_random_username(); $nickname = $username; } // Look for existing user... $wp_user = get_users( array( 'meta_key' => $meta_key, 'meta_value' => $channel_id, 'number' => 1 ) ); $wp_user = reset( $wp_user ); // Check for faulty merge request if ( ! is_user_logged_in() && $cookie['merge'] ) { return 'oauth_invalid_merge_request'; } if ( is_user_logged_in() && ! $cookie['merge'] ) { return 'oauth_invalid_merge_request'; } // If a valid merge has been requested... if ( is_user_logged_in() && ! $wp_user && $cookie['merge'] ) { if ( ! $wp_user && get_current_user_id() === $merge_id ) { $wp_user = get_user_by( 'id', $merge_id ); $merged = true; } // Cannot merge with non-existent user... if ( is_user_logged_in() && ! $wp_user ) { return 'oauth_invalid_merge_user'; } } // Cannot merged if already linked if ( is_user_logged_in() && $wp_user && $merge_id !== $wp_user->ID ) { return 'oauth_already_linked'; } // If still no user has been found, create a new one... if ( ! is_a( $wp_user, 'WP_User' ) ) { // Find free username if ( username_exists( $username ) ) { $alt_username = $username; $discriminator = 1; while ( username_exists( $alt_username ) ) { $alt_username = $username . $discriminator; $discriminator++; } $username = $alt_username; } // Create user $wp_user_id = wp_create_user( $username, wp_generate_password( 32, true, true ), $email ); // Failure or success? if ( is_wp_error( $wp_user_id ) ) { $error_code = $wp_user_id->get_error_code(); return $error_code === 'existing_user_email' ? 'oauth_email_taken' : $error_code; } else { $wp_user = get_user_by( 'id', $wp_user_id ); $new = true; } } // Update user data if ( is_a( $wp_user, 'WP_User' ) ) { // Current avatar $current_avatar = $wp_user->fictioneer_external_avatar_url ?: ''; // Set nickname and display name if ( $new ) { fictioneer_update_user_meta( $wp_user->ID, 'nickname', $nickname ); wp_update_user( array( 'ID' => $wp_user->ID, 'display_name' => $nickname ) ); } // Set channel ID if ( $new || $merged ) { fictioneer_update_user_meta( $wp_user->ID, "fictioneer_{$channel}_id_hash", $channel_id ); } // Update avatar if ( $avatar !== $current_avatar && ! get_the_author_meta( 'fictioneer_lock_avatar', $wp_user->ID ) ) { fictioneer_update_user_meta( $wp_user->ID, 'fictioneer_external_avatar_url', $avatar ); } // Handle Patreon if ( $channel === 'patreon' ) { if ( isset( $user_data['tiers'] ) && count( $user_data['tiers'] ) > 0 ) { fictioneer_update_user_meta( $wp_user->ID, 'fictioneer_patreon_tiers', $user_data['tiers'] ); } else { delete_user_meta( $wp_user->ID, 'fictioneer_patreon_tiers' ); } if ( isset( $user_data['membership'] ) && ! empty( $user_data['membership'] ) ) { fictioneer_update_user_meta( $wp_user->ID, 'fictioneer_patreon_membership', $user_data['membership'] ); } else { delete_user_meta( $wp_user->ID, 'fictioneer_patreon_membership' ); } } // Login if ( ! is_user_logged_in() ) { wp_clear_auth_cookie(); wp_set_current_user( $wp_user->ID ); // Allow login to last three days add_filter( 'auth_cookie_expiration', function( $length ) { return FICTIONEER_OAUTH_COOKIE_EXPIRATION; }); // Set authentication cookie wp_set_auth_cookie( $wp_user->ID, true ); } // Action do_action( 'fictioneer_after_oauth_user', $wp_user, array( 'channel' => $user_data['channel'], 'uid' => $user_data['uid'], 'username' => $user_data['username'], 'nickname' => $user_data['nickname'], 'email' => $user_data['email'], 'avatar_url' => $user_data['avatar'], 'patreon_tiers' => $user_data['tiers'] ?? [], 'patreon_membership' => $user_data['membership'] ?? [], 'new' => $new, 'merged' => $merged ) ); // Return result if ( is_user_logged_in() ) { if ( $new ) { return 'new_user'; } if ( $merged ) { return 'merged_user'; } return 'known_user'; } } // Something went very wrong return 'no_user_found_or_created'; } // ============================================================================= // HELPERS // ============================================================================= /** * Terminate the OAuth 2.0 script and redirect back * * @since 5.19.0 * * @param string $return_url Optional. URL to return to. * @param array $query_args Optional. Additional query arguments for the redirect. * @param string $anchor Optional. Anchor for the return URL. */ function fictioneer_oauth2_terminate( $return_url = null, $query_args = [], $anchor = null ) { // Delete cookie fictioneer_oauth2_delete_cookie(); // Redirect and terminate wp_safe_redirect( add_query_arg( $query_args, $return_url ?? home_url() ) . ( $anchor ? '#' . $anchor : '' ) ); exit; } /** * Outputs a formatted error message and stops the script * * @since 5.5.2 * @since 5.7.5 - Refactored. * @since 5.19.0 - Refactored again. * * @param string $message The error message. * @param string $title Optional. Title of the error page. Defaults to 'Error'. */ function fictioneer_oauth_die( $message, $title = 'Error' ) { // Delete cookie fictioneer_oauth2_delete_cookie(); wp_die( '
' . print_r( $message, true ) . '' . '
The good news is, nothing has happened to your account. The bad new is, something is not working. Please try again later or contact an administrator for help. Back to site
', $title ); } /** * Get the OAuth 2.0 cookie contents * * @since 5.19.0 * * @return array|null The cookie as array or null on failure. */ function fictioneer_oauth2_get_cookie() { if ( isset( $_COOKIE['fictioneer_oauth'] ) ) { return fictioneer_decrypt( $_COOKIE['fictioneer_oauth'] ) ?: null; } return null; } /** * Deleted the OAuth 2.0 cookie * * @since 5.19.0 */ function fictioneer_oauth2_delete_cookie() { setcookie( 'fictioneer_oauth', '', array( 'expires' => time() - 3600, 'path' => '/', 'domain' => '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax' // Necessary because of redirects ) ); } /** * Get code from OAuth 2.0 provider * * @since 5.19.0 * * @param array $args { * Array of arguments. * * @type string $channel The targeted provider. * @type string $return_url The return URL. * @type string $anchor Optional. Anchor for the return URL. * @type bool $merge Optional. Whether this is a merge request. * @type int $merge_id ID of the current user or 0. * } */ function fictioneer_oauth2_get_code( $args ) { // Setup $client_id = get_option( "fictioneer_{$args['channel']}_client_id" ); $client_secret = get_option( "fictioneer_{$args['channel']}_client_secret" ); // Abort if... if ( ! $client_id || ! $client_secret ) { fictioneer_oauth2_terminate( $args['return_url'] ); } // Prepare request $params = array( 'response_type' => 'code', 'client_id' => $client_id, 'state' => hash( 'sha256', microtime( TRUE ) . random_bytes( 15 ) . $_SERVER['REMOTE_ADDR'] ), 'scope' => FCN_OAUTH2_API_ENDPOINTS[ $args['channel'] ]['scope'], 'redirect_uri' => get_site_url( null, FICTIONEER_OAUTH_ENDPOINT ), 'force_verify' => 'true', 'prompt' => 'consent', 'access_type' => 'offline' ); // Set cookie $value = fictioneer_encrypt( array( 'state' => $params['state'], 'channel' => $args['channel'], 'return_url' => $args['return_url'], 'anchor' => $args['anchor'], 'merge' => $args['merge'], 'merge_id' => $args['merge_id'] ) ); if ( $value ) { setcookie( 'fictioneer_oauth', $value, array( 'expires' => time() + 300, 'path' => '/', 'domain' => '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax' // Necessary because of redirects ) ); } // Request code wp_redirect( add_query_arg( $params, FCN_OAUTH2_API_ENDPOINTS[ $args['channel'] ]['login'] ) ); // Terminate exit(); } /** * Get the OAuth 2.0 access token * * @since 5.19.0 * * @param string $url URL to make the API request to. * @param array $body Post body. * @param array $headers Optional. Array of additional header arguments. * * @return object|WP_Error Decoded JSON result or WP_Error on failure. */ function fictioneer_oauth2_get_token( $url, $body, $headers = [] ) { // Params $args = array( 'headers' => array_merge( array( 'Accept' => 'application/json' ), $headers ), 'body' => $body, 'timeout' => 10, 'blocking' => true, 'reject_unsafe_urls' => true, 'data_format' => 'body', 'limit_response_size' => 4096 ); // Request $response = wp_remote_post( $url, $args ); // Error? if ( is_wp_error( $response ) ) { return $response; } // Return decoded body return json_decode( wp_remote_retrieve_body( $response ) ); } /** * Revoke an OAuth 2.0 access token * * @since 4.0.0 * @since 5.7.5 - Refactored. * @since 5.19.0 - Refactored again. * * @param string $url URL to make the API request to. * @param array $post Post body. * * @return string HTTP response code. */ function fictioneer_oauth2_revoke_token( $url, $body ) { // Params $args = array( 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), 'body' => $body ); // Request $response = wp_remote_post( $url, $args ); // Error? if ( is_wp_error( $response ) ) { return 500; // Internal Server Error } // Return response code return wp_remote_retrieve_response_code( $response ); } /** * Retrieve and decode user data response * * @since 5.19.0 * * @param object $response API response. * * @return object User data object. */ function fictioneer_oauth2_retrieve_user_body( $response ) { // Check retrieved response if ( is_wp_error( $response ) ) { fictioneer_oauth_die( implode( '