Created
January 19, 2021 23:55
-
-
Save TimothyBJacobs/94de611c0d36cec70249feac7cf3eda9 to your computer and use it in GitHub Desktop.
App Passwords Client Demo Plugin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare( strict_types=1 ); | |
/* | |
* Plugin Name: Demo App Passwords Client | |
*/ | |
namespace TimothyBJacobs\AppPasswordsClientDemo; | |
const META_KEY = '_app_passwords_client_demo_creds'; | |
const PAGE = 'app-passwords-demo'; | |
const CONNECT_ACTION = 'apd-connect'; | |
const DISCONNECT_ACTION = 'apd-disconnect'; | |
const CALLBACK_ACTION = 'apd-callback'; | |
const APP_ID = '9bacbe61-2a44-5c6a-8ab0-496817caf619'; | |
add_action( 'admin_menu', function () { | |
$hook = add_management_page( __( 'App Passwords Demo' ), __( 'App Passwords Demo' ), 'exist', PAGE, __NAMESPACE__ . '\\render_page' ); | |
add_action( "load-${hook}", __NAMESPACE__ . '\\load' ); | |
} ); | |
function render_page(): void { | |
$url = admin_url( 'tools.php?page=' . PAGE ); | |
$user_id = get_current_user_id(); | |
$me = null; | |
if ( has_credentials( $user_id ) ) { | |
$me = api_request( $user_id, '/wp/v2/users/me' ); | |
} | |
?> | |
<div class="wrap"> | |
<h1><?php _e( 'App Passwords Demo' ) ?></h1> | |
<?php if ( is_wp_error( $me ) ) : ?> | |
<div class="notice notice-error"><p><?php echo esc_html( $me->get_error_message() ); ?></p></div> | |
<?php elseif ( $me ): ?> | |
<pre><?php echo wp_json_encode( $me, JSON_PRETTY_PRINT ); ?></pre> | |
<form method="post" action="<?php echo esc_url( $url ); ?>"> | |
<?php wp_nonce_field( DISCONNECT_ACTION ); ?> | |
<?php submit_button( __( 'Disconnect' ), 'primary', DISCONNECT_ACTION ); ?> | |
</form> | |
<?php else: ?> | |
<form method="post" action="<?php echo esc_url( $url ); ?>"> | |
<div class="form-wrap"> | |
<div class="form-field"> | |
<label for="apd-website"><?php _e( 'Website' ) ?></label> | |
<input type="url" name="apd_website" id="apd-website"/> | |
</div> | |
<?php wp_nonce_field( CONNECT_ACTION ); ?> | |
<?php submit_button( __( 'Connect' ), 'primary', CONNECT_ACTION ); ?> | |
</div> | |
</form> | |
<?php endif; ?> | |
</div> | |
<?php | |
} | |
/** | |
* Runs when the App Passwords Demo page loads. | |
* | |
* Handles form actions. | |
*/ | |
function load(): void { | |
if ( isset( $_POST[ CONNECT_ACTION ] ) ) { | |
check_admin_referer( CONNECT_ACTION ); | |
$redirect = build_authorization_redirect( $_POST['apd_website'] ?? '' ); | |
if ( is_wp_error( $redirect ) ) { | |
wp_die( $redirect ); | |
} | |
// This is intentionally not using wp_safe_redirect() as we are sending the user to another domain. | |
wp_redirect( $redirect ); | |
die; | |
} | |
if ( isset( $_POST[ DISCONNECT_ACTION ] ) ) { | |
check_admin_referer( DISCONNECT_ACTION ); | |
delete_user_meta( get_current_user_id(), META_KEY ); | |
} | |
if ( ! empty( $_GET[ CALLBACK_ACTION ] ) ) { | |
if ( ! wp_verify_nonce( $_GET['state'] ?? '', CALLBACK_ACTION ) ) { | |
wp_nonce_ays( CALLBACK_ACTION ); | |
die; | |
} | |
if ( ( $_GET['success'] ?? '' ) === 'false' ) { | |
wp_die( __( 'Authorization rejected.' ) ); | |
} | |
$site_url = $_GET['site_url'] ?? ''; | |
$user_login = $_GET['user_login'] ?? ''; | |
$password = $_GET['password'] ?? ''; | |
if ( ! $site_url || ! $user_login || ! $password ) { | |
wp_die( __( 'Malformed authorization callback.' ) ); | |
} | |
$root = discover( $site_url ); | |
if ( is_wp_error( $root ) ) { | |
wp_die( $root ); | |
} | |
try { | |
store_credentials( get_current_user_id(), $root, $user_login, $password ); | |
wp_safe_redirect( admin_url( 'tools.php?page=' . PAGE ) ); | |
die; | |
} catch ( \Exception $e ) { | |
wp_die( $e->getMessage() ); | |
} | |
} | |
} | |
/** | |
* Makes an authenticated API request. | |
* | |
* @param int $user_id The user ID to make the request as. | |
* @param string $route The route to access. | |
* | |
* @return array|\WP_Error | |
*/ | |
function api_request( int $user_id, string $route ) { | |
$creds = get_credentials( $user_id ); | |
if ( ! $creds ) { | |
return new \WP_Error( 'no_credentials', __( 'No credentials stored for this user.' ) ); | |
} | |
[ $api_root, $username, $password ] = $creds; | |
$query = wp_parse_url( $api_root, PHP_URL_QUERY ); | |
$url = untrailingslashit( $api_root ) . $route; | |
if ( $query ) { | |
parse_str( $query, $qv ); | |
if ( isset( $qv['rest_route'] ) ) { | |
$url = add_query_arg( 'rest_route', $route, $api_root ); | |
} | |
} | |
$response = wp_safe_remote_get( $url, [ | |
'headers' => [ | |
'Authorization' => 'Basic ' . base64_encode( "{$username}:{$password}" ) | |
] | |
] ); | |
if ( is_wp_error( $response ) ) { | |
return $response; | |
} | |
$status = wp_remote_retrieve_response_code( $response ); | |
if ( $status !== 200 ) { | |
return new \WP_Error( 'non_200_status', sprintf( __( 'The website returned a %d status code.' ), $status ) ); | |
} | |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); | |
if ( JSON_ERROR_NONE !== json_last_error() ) { | |
return new \WP_Error( 'invalid_json', json_last_error_msg() ); | |
} | |
return $body; | |
} | |
/** | |
* Gets the secret key used for encrypting App Password credentials. | |
* | |
* Looks for a `APP_PASSWORDS_CLIENT_SECRET` constant. You can generate your secret key like this: | |
* | |
* wp eval 'echo bin2hex(\Sodium\randombytes_buf(\Sodium\CRYPTO_SECRETBOX_KEYBYTES)) . PHP_EOL;' | |
* | |
* @return string | |
*/ | |
function get_secret_key(): string { | |
if ( ! defined( 'APP_PASSWORDS_CLIENT_SECRET' ) || ! APP_PASSWORDS_CLIENT_SECRET ) { | |
wp_die( 'Must define APP_PASSWORDS_CLIENT_SECRET in your `wp-config.php` file.' ); | |
} | |
return hex2bin( APP_PASSWORDS_CLIENT_SECRET ); | |
} | |
/** | |
* Stores the REST API credentials for the given user. | |
* | |
* @param int $user_id The user ID to store the credentials for. | |
* @param string $api_root The REST API root for the website. | |
* @param string $username The credential username. | |
* @param string $password The credential password. | |
* | |
* @throws \SodiumException | |
*/ | |
function store_credentials( int $user_id, string $api_root, string $username, string $password ) { | |
$key = get_secret_key(); | |
$nonce = \Sodium\randombytes_buf( \Sodium\CRYPTO_SECRETBOX_NONCEBYTES ); | |
$ciphertext = \Sodium\crypto_secretbox( $password, $nonce, $key ); | |
$saved = update_user_meta( $user_id, META_KEY, [ | |
'ciphertext' => bin2hex( $ciphertext ), | |
'nonce' => bin2hex( $nonce ), | |
'username' => $username, | |
'api_root' => $api_root, | |
] ); | |
if ( ! $saved ) { | |
throw new \Exception( 'Failed to save credentials.' ); | |
} | |
} | |
/** | |
* Checks if the user has credentials stored. | |
* | |
* @param int $user_id | |
* | |
* @return bool | |
*/ | |
function has_credentials( int $user_id ): bool { | |
return metadata_exists( 'user', $user_id, META_KEY ); | |
} | |
/** | |
* Gets the REST API credentials for the given user. | |
* | |
* @param int $user_id The user ID to retrieve the credentials for. | |
* | |
* @return array|null An array with the API Root, username, and password, or null if no valid credentials found. | |
*/ | |
function get_credentials( int $user_id ): ?array { | |
$meta = get_user_meta( $user_id, META_KEY, true ); | |
if ( ! $meta ) { | |
return null; | |
} | |
$key = get_secret_key(); | |
$nonce = hex2bin( $meta['nonce'] ); | |
$ciphertext = hex2bin( $meta['ciphertext'] ); | |
$plaintext = \Sodium\crypto_secretbox_open( $ciphertext, $nonce, $key ); | |
if ( $plaintext === false ) { | |
delete_user_meta( $user_id, META_KEY ); | |
return null; | |
} | |
return [ $meta['api_root'], $meta['username'], $plaintext ]; | |
} | |
/** | |
* Builds the authorization redirect link. | |
* | |
* @param string $url | |
* | |
* @return string|\WP_Error | |
*/ | |
function build_authorization_redirect( string $url ) { | |
$auth_url = get_authorize_url( $url ); | |
if ( is_wp_error( $auth_url ) ) { | |
return $auth_url; | |
} | |
$success_url = wp_nonce_url( add_query_arg( [ 'page' => PAGE, CALLBACK_ACTION => '1' ], admin_url( 'tools.php' ) ), CALLBACK_ACTION, 'state' ); | |
return add_query_arg( [ | |
'app_name' => urlencode( __( 'App Passwords Demo' ) ), | |
'app_id' => urlencode( APP_ID ), | |
'success_url' => urlencode( $success_url ), | |
], $auth_url ); | |
} | |
/** | |
* Looks up the Authorize Application URL for the given website. | |
* | |
* @param string $url The website to lookup. | |
* | |
* @return string|\WP_Error The authorization URL or a WP_Error if none found. | |
*/ | |
function get_authorize_url( string $url ) { | |
$root = discover( $url ); | |
if ( is_wp_error( $root ) ) { | |
return $root; | |
} | |
$response = wp_safe_remote_get( $root ); | |
if ( is_wp_error( $response ) ) { | |
return $response; | |
} | |
$status = wp_remote_retrieve_response_code( $response ); | |
if ( $status !== 200 ) { | |
return new \WP_Error( 'non_200_status', sprintf( __( 'The website returned a %d status code.' ), $status ) ); | |
} | |
$index = json_decode( wp_remote_retrieve_body( $response ), true ); | |
if ( JSON_ERROR_NONE !== json_last_error() ) { | |
return new \WP_Error( 'invalid_json', json_last_error_msg() ); | |
} | |
$auth_url = $index['authentication']['application-passwords']['endpoints']['authorization'] ?? ''; | |
if ( ! $auth_url ) { | |
return new \WP_Error( 'no_application_passwords_support', __( 'Application passwords is not available for this website.' ) ); | |
} | |
return $auth_url; | |
} | |
/** | |
* Discovers the REST API root from the given site URL. | |
* | |
* @param string $url | |
* | |
* @return string|\WP_Error | |
*/ | |
function discover( string $url ) { | |
$response = wp_safe_remote_head( $url ); | |
if ( is_wp_error( $response ) ) { | |
return $response; | |
} | |
$link = wp_remote_retrieve_header( $response, 'Link' ); | |
if ( ! $link ) { | |
return new \WP_Error( 'no_link_header', __( 'REST API cannot be discovered. No link header found.' ) ); | |
} | |
$parsed = parse_header_with_attributes( $link ); | |
foreach ( $parsed as $url => $attr ) { | |
if ( ( $attr['rel'] ?? '' ) === 'https://api.w.org/' ) { | |
return $url; | |
} | |
} | |
return new \WP_Error( 'no_link', __( 'REST API cannot be discovered. No REST API link found.' ) ); | |
} | |
/** | |
* Parse a header that has attributes. | |
* | |
* @param string $header | |
* | |
* @return array | |
*/ | |
function parse_header_with_attributes( string $header ): array { | |
$parsed = array(); | |
$list = explode( ',', $header ); | |
foreach ( $list as $value ) { | |
$attrs = array(); | |
$parts = explode( ';', trim( $value ) ); | |
$main = trim( $parts[0], ' <>' ); | |
foreach ( $parts as $part ) { | |
if ( false === strpos( $part, '=' ) ) { | |
continue; | |
} | |
[ $key, $value ] = explode( '=', $part, 2 ); | |
$key = trim( $key ); | |
$value = trim( $value, '" ' ); | |
$attrs[ $key ] = $value; | |
} | |
$parsed[ $main ] = $attrs; | |
} | |
return $parsed; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment