Skip to content

Commit

Permalink
Merge pull request #19 from alleyinteractive/feature/jwt-rest-api-user
Browse files Browse the repository at this point in the history
Allow authenticated users to use JWTs to access the REST API
  • Loading branch information
srtfisher authored Feb 23, 2024
2 parents 9e30307 + 2f1b0d3 commit bb7d522
Show file tree
Hide file tree
Showing 9 changed files with 263 additions and 117 deletions.
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

0 comments on commit bb7d522

Please sign in to comment.