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 authenticated users to use JWTs to access the REST API #19

Merged
merged 8 commits into from
Feb 23, 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 30 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# REST API Guard

Stable tag: 1.1.2
Stable tag: 1.2.0

Requires at least: 6.0

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <token>` 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 );
Expand All @@ -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 <token>` 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=<user_id>]`
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

Expand Down
13 changes: 10 additions & 3 deletions cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<expiration>] [--user=<user>]',
],
);
6 changes: 1 addition & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,14 @@
"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": {
"alleyinteractive/composer-wordpress-autoloader": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"pestphp/pest-plugin": true
},
"platform": {
"php": "8.0"
},
"sort-packages": true
},
"extra": {
Expand Down
24 changes: 12 additions & 12 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
printerClass="NunoMaduro\Collision\Adapters\Phpunit\Printer"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
cacheDirectory=".phpunit.result.cache"
>
<testsuites>
<testsuite name="general">
<directory prefix="test-" suffix=".php">tests</directory>
</testsuite>
</testsuites>
<testsuites>
<testsuite name="general">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
</phpunit>
163 changes: 103 additions & 60 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.1.2
* Version: 1.2.0
* Author: Sean Fisher
* Author URI: https://alley.co/
* Requires at least: 6.0
Expand All @@ -23,6 +23,7 @@
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
use WP_User;

if ( ! defined( 'ABSPATH' ) ) {
exit;
Expand Down Expand Up @@ -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(),
]
);
}
}

Expand Down Expand Up @@ -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 ) );
}

/**
Expand All @@ -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 ) {
Expand Down
Loading
Loading