Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow authentication with a JWT #17

Merged
merged 7 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/built-release.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥳

- 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.
Expand Down
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
<token>` 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.
Expand Down
18 changes: 18 additions & 0 deletions cli.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
* WP-CLI commands.
*
* @package rest-api-guard
*/

use function Alley\WP\REST_API_Guard\generate_jwt;

WP_CLI::add_command(
'rest-api-guard generate-jwt',
function () {
echo generate_jwt() . PHP_EOL; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
},
[
'shortdesc' => __( 'Generate a JSON Web Token (JWT).', 'rest-api-guard' ),
]
);
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -31,6 +32,9 @@
"dealerdirect/phpcodesniffer-composer-installer": true,
"pestphp/pest-plugin": true
},
"platform": {
"php": "8.0"
},
"sort-packages": true
},
"extra": {
Expand Down
141 changes: 137 additions & 4 deletions plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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 );
Expand All @@ -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.
*
Expand Down Expand Up @@ -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',
/**
Expand All @@ -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';
}
48 changes: 46 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
<token>` 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.
Loading