From 48510ae753f695fb208a3e45455cb3bb4b7957f7 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 12:00:43 -0500 Subject: [PATCH 1/7] Add JWT requirement for anonymous API calls --- README.md | 2 +- composer.json | 3 +- plugin.php | 137 +++++++++++++++++++++++++++++++++- readme.txt | 2 +- settings.php | 31 ++++++++ tests/test-rest-api-guard.php | 38 ++++++++++ 6 files changed, 206 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ea7b48..aaf2202 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # REST API Guard -Stable tag: 1.0.4 +Stable tag: 1.1.0 Requires at least: 6.0 diff --git a/composer.json b/composer.json index 3725ab2..9d5e8bb 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ } ], "require": { - "php": "^7.4|^8.0" + "php": "^7.4|^8.0", + "firebase/php-jwt": "^6.10" }, "require-dev": { "alleyinteractive/alley-coding-standards": "^2.0", diff --git a/plugin.php b/plugin.php index 41b7564..2f97a72 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,67 @@ 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' + ); +} diff --git a/readme.txt b/readme.txt index 3466a6f..d16f165 100644 --- a/readme.txt +++ b/readme.txt @@ -1,5 +1,5 @@ === 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 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..2dc10c8 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 ) { + add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); + + $this->expectApplied( 'rest_api_guard_authentication_jwt' )->andReturnTrue(); + + if ( 'valid' === $type ) { + $this->expectApplied( 'rest_api_guard_jwt_issuer' )->andReturnString(); + $this->expectApplied( 'rest_api_guard_jwt_audience' )->andReturnString(); + $this->expectApplied( 'rest_api_guard_jwt_secret' )->andReturnString(); + } + + + $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', '' ], + ]; + } } From 5c4e2bd6e88f8bc072f62974770ad7eed6d03359 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 12:09:59 -0500 Subject: [PATCH 2/7] Add a command to generate a JWT and README --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ cli.php | 18 ++++++++++++++++++ plugin.php | 4 ++++ readme.txt | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 cli.php diff --git a/README.md b/README.md index aaf2202..109af78 100644 --- a/README.md +++ b/README.md @@ -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/plugin.php b/plugin.php index 2f97a72..4dedc09 100644 --- a/plugin.php +++ b/plugin.php @@ -310,3 +310,7 @@ function generate_jwt(): string { 'HS256' ); } + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once __DIR__ . '/cli.php'; +} diff --git a/readme.txt b/readme.txt index d16f165..592efd0 100644 --- a/readme.txt +++ b/readme.txt @@ -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. From 7a81b52307d3ae38eaaea589778dd29755a00a53 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 12:10:31 -0500 Subject: [PATCH 3/7] CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dc604..e46339c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `wp-rest-guard` will be documented in this file. +## v1.1.0 - 2024-012-12 + +- 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. From 97ec2ca8923b138b28437574811602ecaeea25e4 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 12:11:32 -0500 Subject: [PATCH 4/7] Adding the built release workflow --- .github/workflows/built-release.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/built-release.yml 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 From 5d3890617b86f2b8bb6a238e7fde25a2d4f6cef3 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 12:14:19 -0500 Subject: [PATCH 5/7] CHANGELOG and bump to 8.0 --- CHANGELOG.md | 1 + README.md | 2 +- composer.json | 5 ++++- readme.txt | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e46339c..4f0b8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 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 diff --git a/README.md b/README.md index 109af78..f8683b1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Requires at least: 6.0 Tested up to: 6.0 -Requires PHP: 7.4 +Requires PHP: 8.0 License: GPL v2 or later diff --git a/composer.json b/composer.json index 9d5e8bb..2bf8e8c 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ } ], "require": { - "php": "^7.4|^8.0", + "php": "^8.0", "firebase/php-jwt": "^6.10" }, "require-dev": { @@ -32,6 +32,9 @@ "dealerdirect/phpcodesniffer-composer-installer": true, "pestphp/pest-plugin": true }, + "platform": { + "php": "8.0" + }, "sort-packages": true }, "extra": { diff --git a/readme.txt b/readme.txt index 592efd0..356b11f 100644 --- a/readme.txt +++ b/readme.txt @@ -2,7 +2,7 @@ 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 From 83a94c69fd630fa63dff708b791c4c0202cfdb52 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 12:16:18 -0500 Subject: [PATCH 6/7] Downgrading mantle --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2bf8e8c..2f2526a 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "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": { From 1c0981ee92e1ad1663211308d10cc97585ccd842 Mon Sep 17 00:00:00 2001 From: Sean Fisher Date: Fri, 12 Jan 2024 13:10:16 -0500 Subject: [PATCH 7/7] Fixing tests for mantle 0.7 --- tests/test-rest-api-guard.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test-rest-api-guard.php b/tests/test-rest-api-guard.php index 2dc10c8..b2b4a9f 100644 --- a/tests/test-rest-api-guard.php +++ b/tests/test-rest-api-guard.php @@ -217,14 +217,14 @@ public function test_prevent_access_denylist_priority() { * @dataProvider jwtDataProvider */ public function test_jwt_authentication( $type, $token ) { - add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); + $this->expectApplied( 'rest_api_guard_authentication_jwt' ); - $this->expectApplied( 'rest_api_guard_authentication_jwt' )->andReturnTrue(); + add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); if ( 'valid' === $type ) { - $this->expectApplied( 'rest_api_guard_jwt_issuer' )->andReturnString(); - $this->expectApplied( 'rest_api_guard_jwt_audience' )->andReturnString(); - $this->expectApplied( 'rest_api_guard_jwt_secret' )->andReturnString(); + $this->expectApplied( 'rest_api_guard_jwt_issuer' ); + $this->expectApplied( 'rest_api_guard_jwt_audience' ); + $this->expectApplied( 'rest_api_guard_jwt_secret' ); }