diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a9a89..e7c56db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `wp-rest-guard` will be documented in this file. +## v1.2.0 - 2024-02-22 + +- Add support for authenticated users interacting with the REST API. +- Allow settings to be completely disabled via code. +- Increase the default length of the JWT secret to 32 characters. + ## v1.1.1 - 2024-01-15 - Re-releasing to re-trigger the deployment to WordPress.org. diff --git a/README.md b/README.md index 918148f..a4e90c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # REST API Guard -Stable tag: 1.1.2 +Stable tag: 1.2.0 Requires at least: 6.0 @@ -56,7 +56,7 @@ add_filter( 'rest_api_guard_allow_user_access', fn () => true ); ### Preventing Access to Index (`/`) or Namespace Endpoints (`wp/v2`) -To prevent anonymous users from browing your site and discovering what plugins/post types are setup, the plugin restricts access to the index (`/`) and namespace (`wp/v2`) endpoints. This can be prevented in the plugin's settings or via code: +To prevent anonymous users from browsing your site and discovering what plugins/post types are set up, the plugin restricts access to the index (`/`) and namespace (`wp/v2`) endpoints. This can be prevented in the plugin's settings or via code: ```php // Allow index access. @@ -118,11 +118,11 @@ add_filter( ); ``` -### Require JSON Web Token (JWT) Authentication +### Require JSON Web Token (JWT) Authentication for Anonymous Users 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: +access the REST API. Users should pass an `Authorization: Bearer ` header +with their request. This can be configured in the plugin's settings or via code: ```php add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); @@ -134,35 +134,40 @@ Out of the box, the plugin will look for a JWT in the `Authorization: Bearer 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_audience', fn ( string $audience ) => 'custom-audience' ); -add_filter( - 'rest_api_guard_jwt_issuer', - function ( string $issuer ): string { - return 'https://example.com'; - } -); +add_filter( 'rest_api_guard_jwt_issuer', fn ( string $issuer ) => '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'; - } -); +add_filter( 'rest_api_guard_jwt_secret', fn ( string $secret ) => 'my-custom-secret' ); +``` + +### Allow JWT Authentication for Authenticated Users + +Authenticated users can be authenticated with the REST API via a JSON Web Token. +Similar to the anonymous JWT authentication, users should pass an +`Authorization: Bearer ` header with their request. This can be +configured in the plugin's settings or via code: + +```php +add_filter( 'rest_api_guard_user_authentication_jwt', fn () => true ); ``` -You can generate a JWT for use with the REST API by calling the -`wp rest-api-guard generate-jwt` command. +### Generating JWTs for Anonymous and Authenticated Users + +JWTs can be generated by calling the `wp rest-api-guard generate-jwt [--user=]` +command or using the `Alley\WP\REST_API_Guard\generate_jwt()` method: + +```php +$jwt = \Alley\WP\REST_API_Guard\generate_jwt( + expiration: 3600, // Optional. The expiration time in seconds from now. + user: 1, // Optional. The user ID to generate the JWT for. Supports `WP_User` or user ID. +); +``` ## Testing diff --git a/cli.php b/cli.php index 9eadb43..6bb2480 100644 --- a/cli.php +++ b/cli.php @@ -9,10 +9,17 @@ WP_CLI::add_command( 'rest-api-guard generate-jwt', - function () { - echo generate_jwt() . PHP_EOL; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + function ( $args, $assoc_args ) { + $expiration = isset( $assoc_args['expiration'] ) ? (int) $assoc_args['expiration'] : null; + $user = isset( $assoc_args['user'] ) ? (int) $assoc_args['user'] : null; + + echo generate_jwt( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + expiration: $expiration, + user: $user, + ) . PHP_EOL; }, [ 'shortdesc' => __( 'Generate a JSON Web Token (JWT).', 'rest-api-guard' ), - ] + 'synopsis' => '[--expiration=] [--user=]', + ], ); diff --git a/composer.json b/composer.json index 2bc8bb6..71cdd63 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,7 @@ "require-dev": { "alleyinteractive/alley-coding-standards": "^2.0", "alleyinteractive/composer-wordpress-autoloader": "^1.0", - "mantle-framework/testkit": "^0.9", - "nunomaduro/collision": "^5.0" + "mantle-framework/testkit": "^1.0" }, "config": { "allow-plugins": { @@ -32,9 +31,6 @@ "dealerdirect/phpcodesniffer-composer-installer": true, "pestphp/pest-plugin": true }, - "platform": { - "php": "8.0" - }, "sort-packages": true }, "extra": { diff --git a/phpunit.xml b/phpunit.xml index 0595632..80ad28a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,15 +1,15 @@ + - - - tests - - + + + tests + + diff --git a/plugin.php b/plugin.php index 315b0f2..7434adc 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.1.2 + * Version: 1.2.0 * Author: Sean Fisher * Author URI: https://alley.co/ * Requires at least: 6.0 @@ -23,6 +23,7 @@ use WP_Error; use WP_REST_Request; use WP_REST_Server; +use WP_User; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -58,55 +59,78 @@ function should_prevent_anonymous_access( WP_REST_Server $server, WP_REST_Reques $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' ) ); + if ( class_exists( JWT::class ) ) { + /** + * 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. + */ + $require_anonymous_jwt = true === apply_filters( 'rest_api_guard_authentication_jwt', $settings['authentication_jwt'] ?? false, $request ); + $allow_user_jwt = true === apply_filters( 'rest_api_guard_user_authentication_jwt', $settings['user_authentication_jwt'] ?? false, $request ); + + if ( $require_anonymous_jwt || $allow_user_jwt ) { + try { + $jwt = $request->get_header( 'Authorization' ); + + if ( empty( $jwt ) && $require_anonymous_jwt ) { + throw new InvalidArgumentException( __( 'No authorization header token was found and is required for this request.', 'rest-api-guard' ) ); + } + + if ( ! empty( $jwt ) ) { + 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' ) ); + } + + if ( $allow_user_jwt && ! empty( $decoded->sub ) ) { + $user = get_user_by( 'id', $decoded->sub ); + + if ( ! $user instanceof WP_User ) { + throw new InvalidArgumentException( __( 'Invalid user in JWT sub.', 'rest-api-guard' ) ); + } + + wp_set_current_user( $user->ID ); + + return false; + } + } + } catch ( \Exception $error ) { + return new WP_Error( + 'rest_api_guard_unauthorized', + /** + * Filter the authorization error message. + * + * @param string $message The error message being returned. + * @param \Throwable $error The error that occurred. + */ + apply_filters( + 'rest_api_guard_invalid_jwt_message', + sprintf( + /* translators: %s: The error message. */ + __( 'Error authentication with token: %s', 'rest-api-guard' ), + $error->getMessage(), + ), + $error, + ), + [ + 'status' => rest_authorization_required_code(), + ] + ); } - } 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(), - ] - ); } } @@ -283,7 +307,7 @@ function get_jwt_audience(): 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 ) ); + update_option( 'rest_api_guard_jwt_secret', wp_generate_password( 32, false ) ); } /** @@ -297,18 +321,37 @@ function get_jwt_secret(): string { /** * Generate a JSON Web Token (JWT). * + * The JWT payload is intentionally not filtered to prevent + * + * @param int|null $expiration The expiration time of the JWT in seconds or null for no expiration. + * @param WP_User|int|null $user The user to include in the JWT or null for no user. * @return string + * + * @throws InvalidArgumentException If the user is invalid or unknown. */ -function generate_jwt(): string { - return JWT::encode( - [ - 'iss' => get_jwt_issuer(), - 'aud' => get_jwt_audience(), - 'iat' => time(), - ], - get_jwt_secret(), - 'HS256' - ); +function generate_jwt( ?int $expiration = null, WP_User|int|null $user = null ): string { + $payload = [ + 'iss' => get_jwt_issuer(), + 'aud' => get_jwt_audience(), + 'iat' => time(), + ]; + + if ( null !== $expiration ) { + $payload['exp'] = time() + $expiration; + } + + if ( null !== $user ) { + $user = $user instanceof WP_User ? $user : get_user_by( 'id', $user ); + + if ( ! $user instanceof WP_User ) { + throw new InvalidArgumentException( esc_html__( 'Invalid user.', 'rest-api-guard' ) ); + } + + $payload['sub'] = $user->ID; + $payload['user_login'] = $user->user_login; + } + + return JWT::encode( $payload, get_jwt_secret(), 'HS256' ); } if ( defined( 'WP_CLI' ) && WP_CLI ) { diff --git a/readme.txt b/readme.txt index 0ec4239..e9e0902 100644 --- a/readme.txt +++ b/readme.txt @@ -1,5 +1,5 @@ === REST API Guard === -Stable tag: 1.1.2 +Stable tag: 1.2.0 Requires at least: 6.0 Tested up to: 6.3 Requires PHP: 8.0 @@ -71,7 +71,11 @@ Anonymous users can be granted access only to specific namespaces/routes. Reques ### Restrict Anonymous Access to Specific Namespaces/Routes (Denylist) -Anonymous users can be restricted from specific namespaces/routes. This acts as a denylist for specific paths that an anonymous user cannot access. The paths support regular expressions for matching. The use of the [Allowlist](#limit-anonymous-access-to-specific-namespacesroutes-allowlist) takes priority over this denylist. This can be configured in the plugin's settings or via code: +Anonymous users can be restricted from specific namespaces/routes. This acts as +a denylist for specific paths that an anonymous user cannot access. The paths +support regular expressions for matching. The use of the allowlist takes +priority over this denylist. This can be configured in the plugin's settings or +via code: add_filter( 'rest_api_guard_anonymous_requests_denylist', @@ -88,13 +92,15 @@ Anonymous users can be restricted from specific namespaces/routes. This acts as ### 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: +access the REST API. Users should pass an `Authorization: Bearer ` header +with their request. This can be configured in the plugin's settings or via code: 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: +` 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: add_filter( 'rest_api_guard_jwt_audience', @@ -120,5 +126,22 @@ The JWT's secret will be autogenerated and stored in the database in the } ); -You can generate a JWT for use with the REST API by calling the -`wp rest-api-guard generate-jwt` command. +### Allow JWT Authentication for Authenticated Users + +Authenticated users can be authenticated with the REST API via a JSON Web Token. +Similar to the anonymous JWT authentication, users should pass an +`Authorization: Bearer ` header with their request. This can be +configured in the plugin's settings or via code: + + add_filter( 'rest_api_guard_user_authentication_jwt', fn () => true ); + +### Generating JWTs for Anonymous and Authenticated Users + +JWTs can be generated by calling the +`wp rest-api-guard generate-jwt [--user=]` command or using the +`Alley\WP\REST_API_Guard\generate_jwt()` method: + + $jwt = \Alley\WP\REST_API_Guard\generate_jwt( + expiration: 3600, // Optional. The expiration time in seconds from now. + user: 1, // Optional. The user ID to generate the JWT for. Supports `WP_User` or user ID. + ); diff --git a/settings.php b/settings.php index e5a394e..0279dca 100644 --- a/settings.php +++ b/settings.php @@ -27,6 +27,15 @@ * Register the Admin Settings page. */ function on_admin_menu() { + /** + * Filter to disable the admin settings page. + * + * @param bool $disable Whether to disable the admin settings page. + */ + if ( true === apply_filters( 'rest_api_guard_disable_admin_settings', false ) ) { + return; + } + add_options_page( __( 'REST API Guard', 'rest-api-guard' ), __( 'REST API Guard', 'rest-api-guard' ), @@ -175,7 +184,7 @@ function on_admin_init() { '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' ), + __( '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. When using the token, the user will have unrestricted read-only access to the REST API.', 'rest-api-guard' ), get_jwt_audience(), get_jwt_issuer(), ), @@ -184,6 +193,26 @@ function on_admin_init() { 'type' => 'checkbox', ], ); + + add_settings_field( + 'user_authentication_jwt', + __( 'Allow User Authentication with JSON Web Token', 'rest-api-guard' ), + __NAMESPACE__ . '\render_field', + SETTINGS_KEY, + SETTINGS_KEY, + [ + 'description' => __( 'Allow user authentication with a JSON Web Token (JWT) for all requests.', 'rest-api-guard' ), + 'additional' => sprintf( + /* translators: 1: The JWT audience. 2: The JWT issuer. */ + __( 'When enabled, the plugin will allow JWTs to be generated against authenticated users. They can be passed as a "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. When using the token, the user will have unrestricted access to the REST API mirroring whatever permissions the user associated with the token would have.', 'rest-api-guard' ), + get_jwt_audience(), + get_jwt_issuer(), + ), + 'filter' => 'rest_api_guard_user_authentication_jwt', + 'id' => 'user_authentication_jwt', + 'type' => 'checkbox', + ], + ); } } @@ -205,6 +234,8 @@ function sanitize_settings( $input ) { 'allow_user_access' => ! empty( $input['allow_user_access'] ), 'anonymous_requests_allowlist' => ! empty( $input['anonymous_requests_allowlist'] ) ? sanitize_textarea_field( $input['anonymous_requests_allowlist'] ) : '', 'anonymous_requests_denylist' => ! empty( $input['anonymous_requests_denylist'] ) ? sanitize_textarea_field( $input['anonymous_requests_denylist'] ) : '', + 'authentication_jwt' => ! empty( $input['authentication_jwt'] ), + 'user_authentication_jwt' => ! empty( $input['user_authentication_jwt'] ), ]; } diff --git a/tests/test-rest-api-guard.php b/tests/RestApiGuardTest.php similarity index 82% rename from tests/test-rest-api-guard.php rename to tests/RestApiGuardTest.php index b2b4a9f..2b523f0 100644 --- a/tests/test-rest-api-guard.php +++ b/tests/RestApiGuardTest.php @@ -10,7 +10,7 @@ /** * Visit {@see https://mantle.alley.co/testing/test-framework.html} to learn more. */ -class Test_REST_API_Guard extends Test_Case { +class RestApiGuardTest extends Test_Case { protected function setUp(): void { parent::setUp(); @@ -214,9 +214,9 @@ public function test_prevent_access_denylist_priority() { } /** - * @dataProvider jwtDataProvider + * @dataProvider jwtDataProviderAnonymous */ - public function test_jwt_authentication( $type, $token ) { + public function test_jwt_authentication_anonymous( string $type, string $token ) { $this->expectApplied( 'rest_api_guard_authentication_jwt' ); add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); @@ -237,13 +237,48 @@ public function test_jwt_authentication( $type, $token ) { } else { $request->assertUnauthorized(); } + + // Ensure they are always unauthenticated. + $this->get( '/wp-json/wp/v2/users/me' )->assertUnauthorized(); } - public static function jwtDataProvider(): array { + public static function jwtDataProviderAnonymous(): array { return [ 'valid' => [ 'valid', generate_jwt() ], 'invalid' => [ 'invalid', 'invalid' ], 'empty' => [ 'invalid', '' ], ]; } + + /** + * @dataProvider jwtDataProviderAuthenticated + */ + public function test_jwt_authentication_authenticated( string $type, string $token ) { + add_filter( 'rest_api_guard_authentication_jwt', fn () => true ); + add_filter( 'rest_api_guard_user_authentication_jwt', fn () => true ); + + $request = $this + ->with_header( 'Authorization', "Bearer $token" ) + ->get( '/wp-json/wp/v2/users/me' ); + + if ( 'valid' === $type ) { + $request->assertOk()->assertJsonPathExists( 'id' ); + + // Ensure they can access the REST API normally. + $this->get( '/wp-json/wp/v2/posts' )->assertOk(); + } else { + $request->assertUnauthorized(); + + // Ensure they cannot access the REST API normally. + $this->get( '/wp-json/wp/v2/posts' )->assertUnauthorized(); + } + } + + public static function jwtDataProviderAuthenticated(): array { + return [ + 'valid' => [ 'valid', generate_jwt( user: static::factory()->user->create_and_get() ) ], + 'invalid' => [ 'invalid', substr( generate_jwt(), 0, 20 ) ], + 'empty' => [ 'invalid', '' ], + ]; + } }