diff --git a/.github/workflows/built-release.yml b/.github/workflows/built-release.yml new file mode 100644 index 0000000..93a5a78 --- /dev/null +++ b/.github/workflows/built-release.yml @@ -0,0 +1,18 @@ +name: Built Release + +on: + push: + branches: + - develop + +jobs: + built-release: + uses: alleyinteractive/.github/.github/workflows/built-release.yml@main + if: ${{ github.repository != 'alleyinteractive/create-wordpress-plugin' }} + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + with: + node: 16 + php: '8.2' + composer_install: true + draft: false diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dc604..4f0b8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `wp-rest-guard` will be documented in this file. +## v1.1.0 - 2024-012-12 + +- Drops support for PHP 7.4 and requires PHP 8.0. +- Add feature to allow anonymous authentication with a JSON Web Token (JWT). + ## v1.0.4 - 2024-01-12 - Fixing an issue splitting lines by `\n` instead of `\r\n` on Windows. diff --git a/README.md b/README.md index 0ea7b48..f8683b1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # REST API Guard -Stable tag: 1.0.4 +Stable tag: 1.1.0 Requires at least: 6.0 Tested up to: 6.0 -Requires PHP: 7.4 +Requires PHP: 8.0 License: GPL v2 or later @@ -118,6 +118,52 @@ add_filter( ); ``` +### Require JSON Web Token (JWT) Authentication + +Anonymous users can be required to authenticate via a JSON Web Token (JWT) to +access the REST API. This can be configured in the plugin's settings or via +code: + +```php +add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); +``` + +Out of the box, the plugin will look for a JWT in the `Authorization: Bearer +` header. The JWT will be expected to have an audience of +'wordpress-rest-api' and issuer of the site's URL. This can be configured in the +plugin's settings or via code: + +```php +add_filter( + 'rest_api_guard_jwt_audience', + function ( string $audience ): string { + return 'custom-audience'; + } +); + +add_filter( + 'rest_api_guard_jwt_issuer', + function ( string $issuer ): string { + return 'https://example.com'; + } +); +``` + +The JWT's secret will be autogenerated and stored in the +`rest_api_guard_jwt_secret` option. The secret can also be filtered via code: + +```php +add_filter( + 'rest_api_guard_jwt_secret', + function ( string $secret ): string { + return 'my-custom-secret'; + } +); +``` + +You can generate a JWT for use with the REST API by calling the +`wp rest-api-guard generate-jwt` command. + ## Testing Run `composer test` to run tests against PHPUnit and the PHP code in the plugin. diff --git a/cli.php b/cli.php new file mode 100644 index 0000000..9eadb43 --- /dev/null +++ b/cli.php @@ -0,0 +1,18 @@ + __( 'Generate a JSON Web Token (JWT).', 'rest-api-guard' ), + ] +); diff --git a/composer.json b/composer.json index 3725ab2..2f2526a 100644 --- a/composer.json +++ b/composer.json @@ -17,12 +17,13 @@ } ], "require": { - "php": "^7.4|^8.0" + "php": "^8.0", + "firebase/php-jwt": "^6.10" }, "require-dev": { "alleyinteractive/alley-coding-standards": "^2.0", "alleyinteractive/composer-wordpress-autoloader": "^1.0", - "mantle-framework/testkit": "^0.12", + "mantle-framework/testkit": "^0.7", "nunomaduro/collision": "^5.0" }, "config": { @@ -31,6 +32,9 @@ "dealerdirect/phpcodesniffer-composer-installer": true, "pestphp/pest-plugin": true }, + "platform": { + "php": "8.0" + }, "sort-packages": true }, "extra": { diff --git a/plugin.php b/plugin.php index 41b7564..4dedc09 100644 --- a/plugin.php +++ b/plugin.php @@ -3,7 +3,7 @@ * Plugin Name: REST API Guard * Plugin URI: https://github.com/alleyinteractive/wp-rest-api-guard * Description: Restrict and control access to the REST API - * Version: 1.0.4 + * Version: 1.1.0 * Author: Sean Fisher * Author URI: https://alley.co/ * Requires at least: 6.0 @@ -17,6 +17,9 @@ namespace Alley\WP\REST_API_Guard; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use InvalidArgumentException; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -29,6 +32,10 @@ * Instantiate the plugin. */ function main() { + if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) { + require_once __DIR__ . '/vendor/autoload.php'; + } + require_once __DIR__ . '/settings.php'; add_filter( 'rest_pre_dispatch', __NAMESPACE__ . '\on_rest_pre_dispatch', 10, 3 ); @@ -40,15 +47,69 @@ function main() { * * @param WP_REST_Server $server Server instance. * @param WP_REST_Request $request The request object. - * @return bool + * @return WP_Error|bool + * + * @throws InvalidArgumentException If the JWT is invalid. */ -function should_prevent_anonymous_access( WP_REST_Server $server, WP_REST_Request $request ): bool { +function should_prevent_anonymous_access( WP_REST_Server $server, WP_REST_Request $request ): WP_Error|bool { $settings = (array) get_option( SETTINGS_KEY ); if ( ! is_array( $settings ) ) { $settings = []; } + /** + * Check if the anonymous request requires a JSON Web Token (JWT). + * + * @param bool $require Whether to require a JWT, default false. + * @param \WP_REST_Request $request REST API Request. + */ + if ( class_exists( JWT::class ) && true === apply_filters( 'rest_api_guard_authentication_jwt', $settings['authentication_jwt'] ?? false, $request ) ) { + try { + $jwt = $request->get_header( 'Authorization' ); + + if ( empty( $jwt ) ) { + throw new InvalidArgumentException( __( 'No authorization header was found.', 'rest-api-guard' ) ); + } + + if ( 0 !== strpos( $jwt, 'Bearer ' ) ) { + throw new InvalidArgumentException( __( 'Invalid authorization header.', 'rest-api-guard' ) ); + } + + $decoded = JWT::decode( + substr( $jwt, 7 ), + new Key( get_jwt_secret(), 'HS256' ), + ); + + // Verify the contents of the JWT. + if ( empty( $decoded->iss ) || get_jwt_issuer() !== $decoded->iss ) { + throw new InvalidArgumentException( __( 'Invalid JWT issuer.', 'rest-api-guard' ) ); + } + + if ( empty( $decoded->aud ) || get_jwt_audience() !== $decoded->aud ) { + throw new InvalidArgumentException( __( 'Invalid JWT audience.', 'rest-api-guard' ) ); + } + } catch ( \Exception $error ) { + return new WP_Error( + 'rest_api_guard_unauthorized', + /** + * Filter the authorization error message. + * + * @param string $message The error message. + * @param \Throwable $error The error that occurred. + */ + apply_filters( + 'rest_api_guard_invalid_jwt_message', + __( 'Invalid authorization header.', 'rest-api-guard' ), + $error, + ), + [ + 'status' => rest_authorization_required_code(), + ] + ); + } + } + /** * Check if anonymous access is prevent by default. * @@ -161,7 +222,11 @@ function on_rest_pre_dispatch( $pre, $server, $request ) { return $pre; } - if ( should_prevent_anonymous_access( $server, $request ) ) { + $should_prevent = should_prevent_anonymous_access( $server, $request ); + + if ( is_wp_error( $should_prevent ) ) { + return $should_prevent; + } elseif ( $should_prevent ) { return new WP_Error( 'rest_api_guard_unauthorized', /** @@ -181,3 +246,71 @@ function on_rest_pre_dispatch( $pre, $server, $request ) { return $pre; } + +/** + * Get the JSON Web Token (JWT) issuer. + * + * @return string + */ +function get_jwt_issuer(): string { + /** + * Filter the issuer of the JWT. + * + * @param string $issuer The issuer of the JWT. + */ + return apply_filters( 'rest_api_guard_jwt_issuer', get_bloginfo( 'url' ) ); +} + +/** + * Get the JSON Web Token (JWT) audience. + * + * @return string + */ +function get_jwt_audience(): string { + /** + * Filter the audience of the JWT. + * + * @param string $audience The audience of the JWT. + */ + return apply_filters( 'rest_api_guard_jwt_audience', 'wordpress-rest-api' ); +} + +/** + * Get the JSON Web Token (JWT) secret. + * + * @return string + */ +function get_jwt_secret(): string { + // Generate the JWT secret if it does not exist. + if ( empty( get_option( 'rest_api_guard_jwt_secret' ) ) ) { + update_option( 'rest_api_guard_jwt_secret', wp_generate_password( 12, false ) ); + } + + /** + * Filter the secret of the JWT. By default, the WordPress secret key is used. + * + * @param string $secret The secret of the JWT. + */ + return apply_filters( 'rest_api_guard_jwt_secret', get_option( 'rest_api_guard_jwt_secret' ) ); +} + +/** + * Generate a JSON Web Token (JWT). + * + * @return string + */ +function generate_jwt(): string { + return JWT::encode( + [ + 'iss' => get_jwt_issuer(), + 'aud' => get_jwt_audience(), + 'iat' => time(), + ], + get_jwt_secret(), + 'HS256' + ); +} + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once __DIR__ . '/cli.php'; +} diff --git a/readme.txt b/readme.txt index 3466a6f..356b11f 100644 --- a/readme.txt +++ b/readme.txt @@ -1,8 +1,8 @@ === REST API Guard === -Stable tag: 1.0.4 +Stable tag: 1.1.0 Requires at least: 6.0 Tested up to: 6.3 -Requires PHP: 7.4 +Requires PHP: 8.0 License: GPL v2 or later Tags: alleyinteractive, rest-api-guard Contributors: sean212 @@ -84,3 +84,47 @@ Anonymous users can be restricted from specific namespaces/routes. This acts as 10, 2 ); + +### Require JSON Web Token (JWT) Authentication + +Anonymous users can be required to authenticate via a JSON Web Token (JWT) to +access the REST API. This can be configured in the plugin's settings or via +code: + +```php +add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); +``` + +Out of the box, the plugin will look for a JWT in the `Authorization: Bearer +` header. The JWT will be expected to have an audience of 'wordpress-rest-api' and issuer of the site's URL. This can be configured in the plugin's settings or via code: + +```php +add_filter( + 'rest_api_guard_jwt_audience', + function ( string $audience ): string { + return 'custom-audience'; + } +); + +add_filter( + 'rest_api_guard_jwt_issuer', + function ( string $issuer ): string { + return 'https://example.com'; + } +); +``` + +The JWT's secret will be autogenerated and stored in the database in the +`rest_api_guard_jwt_secret` option. The secret can also be changed via code: + +```php +add_filter( + 'rest_api_guard_jwt_secret', + function ( string $secret ): string { + return 'my-custom-secret'; + } +); +``` + +You can generate a JWT for use with the REST API by calling the +`wp rest-api-guard generate-jwt` command. diff --git a/settings.php b/settings.php index 7158ab2..e5a394e 100644 --- a/settings.php +++ b/settings.php @@ -7,6 +7,8 @@ namespace Alley\WP\REST_API_Guard; +use Firebase\JWT\JWT; + if ( ! defined( 'ABSPATH' ) ) { exit; } @@ -161,6 +163,28 @@ function on_admin_init() { 'type' => 'textarea', ], ); + + if ( class_exists( JWT::class ) ) { + add_settings_field( + 'authentication_jwt', + __( 'Require Authentication with JSON Web Token', 'rest-api-guard' ), + __NAMESPACE__ . '\render_field', + SETTINGS_KEY, + SETTINGS_KEY, + [ + 'description' => __( 'Require authentication with a JSON Web Token (JWT) for all anonymous requests.', 'rest-api-guard' ), + 'additional' => sprintf( + /* translators: 1: The JWT audience. 2: The JWT issuer. */ + __( 'When enabled, the plugin will require anonymous users to pass an "Authorization: Bearer " with the token being a valid JSON Web Token (JWT). The plugin will be expecting a JWT with an audience of "%1$s", issuer of "%2$s", and secret that matches the value of the "rest_api_guard_jwt_secret" option.', 'rest-api-guard' ), + get_jwt_audience(), + get_jwt_issuer(), + ), + 'filter' => 'rest_api_guard_authentication_jwt', + 'id' => 'authentication_jwt', + 'type' => 'checkbox', + ], + ); + } } /** @@ -232,6 +256,13 @@ function render_field( array $input ) { break; } + if ( ! empty( $input['additional'] ) ) { + printf( + '

%s

', + esc_html( $input['additional'] ) + ); + } + if ( $disabled ) { printf( '

%s

', diff --git a/tests/test-rest-api-guard.php b/tests/test-rest-api-guard.php index c5d8737..b2b4a9f 100644 --- a/tests/test-rest-api-guard.php +++ b/tests/test-rest-api-guard.php @@ -1,6 +1,10 @@ get( rest_url( '/wp/v2/tags' ) )->assertOk(); $this->get( rest_url( '/wp/v2/categories' ) )->assertUnauthorized(); } + + /** + * @dataProvider jwtDataProvider + */ + public function test_jwt_authentication( $type, $token ) { + $this->expectApplied( 'rest_api_guard_authentication_jwt' ); + + add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); + + if ( 'valid' === $type ) { + $this->expectApplied( 'rest_api_guard_jwt_issuer' ); + $this->expectApplied( 'rest_api_guard_jwt_audience' ); + $this->expectApplied( 'rest_api_guard_jwt_secret' ); + } + + + $request = $this + ->with_header( 'Authorization', "Bearer $token" ) + ->get( '/wp-json/wp/v2/posts' ); + + if ( 'valid' === $type ) { + $request->assertOk(); + } else { + $request->assertUnauthorized(); + } + } + + public static function jwtDataProvider(): array { + return [ + 'valid' => [ 'valid', generate_jwt() ], + 'invalid' => [ 'invalid', 'invalid' ], + 'empty' => [ 'invalid', '' ], + ]; + } }