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

Adds user_secret validation for auth_token and refresh_token #80

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
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
122 changes: 79 additions & 43 deletions src/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Auth {
protected static $issued;
protected static $expiration;
protected static $is_refresh_token = false;
protected static $refresh_token_valid_days = 30;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think 30 days is probably a fine default instead of a year. If you're not using an application for more than 30 days, the application asking you to login again seems reasonable.

protected static $auth_token_is_stateful = true;

/**
* This returns the secret key, using the defined constant if defined, and passing it through a filter to
Expand All @@ -24,6 +26,7 @@ public static function get_secret_key() {

// Use the defined secret key, if it exists
$secret_key = defined( 'GRAPHQL_JWT_AUTH_SECRET_KEY' ) && ! empty( GRAPHQL_JWT_AUTH_SECRET_KEY ) ? GRAPHQL_JWT_AUTH_SECRET_KEY : 'graphql-jwt-auth';

return apply_filters( 'graphql_jwt_auth_secret_key', $secret_key );

}
Expand Down Expand Up @@ -64,6 +67,7 @@ public static function login_and_get_token( $username, $password ) {
*/
wp_set_current_user( $user->data->ID );


/**
* The token is signed, now create the object with basic user data to send to the client
*/
Expand Down Expand Up @@ -123,7 +127,7 @@ public static function get_token_expiration() {
/**
* Retrieves validates user and retrieve signed token
*
* @param \WP_User $user Owner of the token.
* @param \WP_User $user Owner of the token.
* @param bool $cap_check Whether to check capabilities when getting the token
*
* @return null|string
Expand All @@ -137,11 +141,27 @@ protected static function get_signed_token( $user, $cap_check = true ) {
return new \WP_Error( 'graphql-jwt-no-permissions', __( 'Only the user requesting a token can get a token issued for them', 'wp-graphql-jwt-authentication' ) );
}


$secret = null;

if( self::$auth_token_is_stateful || self::$is_refresh_token) {
$secret = Auth::get_user_jwt_secret( $user->ID );

/**
* Only allow access to a new token, if a valid user_secret is given and has not been revoked.
*/
if ( empty( $secret ) || is_wp_error( $secret ) ) {
return new \WP_Error( 'graphql-jwt-no-permissions', __( 'User secret of requesting user has been revoked or is missing.', 'wp-graphql-jwt-authentication' ) );
}
}



/**
* Determine the "not before" value for use in the token
*
* @param string $issued The timestamp of the authentication, used in the token
* @param \WP_User $user The authenticated user
* @param string $issued The timestamp of the authentication, used in the token
* @param \WP_User $user The authenticated user
*/
$not_before = apply_filters( 'graphql_jwt_auth_not_before', self::get_token_issued(), $user );

Expand All @@ -156,15 +176,17 @@ protected static function get_signed_token( $user, $cap_check = true ) {
'exp' => self::get_token_expiration(),
'data' => [
'user' => [
'id' => $user->data->ID,
'id' => $user->data->ID,
'user_secret' => $secret,
Copy link
Collaborator

Choose a reason for hiding this comment

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

At the moment, this is the one thing that makes a refresh token different than an access token. Having this in both tokens makes the tokens identical.

],
],
];


/**
* Filter the token, allowing for individual systems to configure the token as needed
*
* @param array $token The token array that will be encoded
* @param array $token The token array that will be encoded
* @param \WP_User $token The authenticated user
*/
$token = apply_filters( 'graphql_jwt_auth_token_before_sign', $token, $user );
Expand All @@ -180,8 +202,8 @@ protected static function get_signed_token( $user, $cap_check = true ) {
*
* For example, if the user should not be granted a token for whatever reason, a filter could have the token return null.
*
* @param string $token The signed JWT token that will be returned
* @param int $user_id The User the JWT is associated with
* @param string $token The signed JWT token that will be returned
* @param int $user_id The User the JWT is associated with
*/
$token = apply_filters( 'graphql_jwt_auth_signed_token', $token, $user->ID );

Expand Down Expand Up @@ -213,7 +235,7 @@ public static function get_user_jwt_secret( $user_id ) {
/**
* Filter the capability that is tied to editing/viewing user JWT Auth info
*
* @param string 'edit_users'
* @param string 'edit_users'
* @param int $user_id
*/
$capability = apply_filters( 'graphql_jwt_auth_edit_users_capability', 'edit_users', $user_id );
Expand Down Expand Up @@ -241,8 +263,8 @@ public static function get_user_jwt_secret( $user_id ) {
/**
* Return the $secret
*
* @param string $secret The GraphQL JWT Auth Secret associated with the user
* @param int $user_id The ID of the user the secret is associated with
* @param string $secret The GraphQL JWT Auth Secret associated with the user
* @param int $user_id The ID of the user the secret is associated with
*/
return apply_filters( 'graphql_jwt_auth_user_secret', $secret, $user_id );
}
Expand Down Expand Up @@ -302,8 +324,34 @@ public static function get_token( $user, $cap_check = true ) {
return self::get_signed_token( $user, $cap_check );
}

/**
* Filter the token signature, adding the user_secret to the signature and making the
* expiration long lived if it is a refresh_token so that the token can be used for a long time without the client having to store a new
* one.
*
* @param array $token Token.
* @param \WP_User $user User associated with token.
*
* @return array $token
*/
public static function increase_refresh_tokens_valid_days( $token, \WP_User $user ) {

/**
* Set the expiration date to $refresh_token_valid_days (days) from now to make the refresh token long lived, allowing the
* token to be valid without changing as long as it has not been revoked or otherwise invalidated,
* such as a refreshed user secret.
*/
if ( self::$is_refresh_token ) {
$token['exp'] = apply_filters( 'graphql_jwt_auth_refresh_token_expiration', ( self::get_token_issued() + ( DAY_IN_SECONDS * self::$refresh_token_valid_days ) ) );
self::$is_refresh_token = false;
}

return $token;
}

/**
* Given a WP_User, this returns a refresh token for the user
*
* @param \WP_User $user A WP_User object
* @param bool $cap_check
*
Expand All @@ -313,34 +361,12 @@ public static function get_refresh_token( $user, $cap_check = true ) {

self::$is_refresh_token = true;

/**
* Filter the token signature for refresh tokens, adding the user_secret to the signature and making the
* expiration long lived so that the token can be used for a long time without the client having to store a new
* one.
*/
add_filter( 'graphql_jwt_auth_token_before_sign', function( $token, \WP_User $user ) {
$secret = Auth::get_user_jwt_secret( $user->ID );

if ( ! empty( $secret ) && ! is_wp_error( $secret ) && true === self::is_refresh_token() ) {

/**
* Set the expiration date as a year from now to make the refresh token long lived, allowing the
* token to be valid without changing as long as it has not been revoked or otherwise invalidated,
* such as a refreshed user secret.
*/
$token['exp'] = apply_filters( 'graphql_jwt_auth_refresh_token_expiration', ( self::get_token_issued() + ( DAY_IN_SECONDS * 365 ) ) );
$token['data']['user']['user_secret'] = $secret;

self::$is_refresh_token = false;

}

return $token;
}, 10, 2 );
add_filter( 'graphql_jwt_auth_token_before_sign', [ __CLASS__, 'increase_refresh_tokens_valid_days' ], 10, 2 );

return self::get_signed_token( $user, $cap_check );
}


public static function is_refresh_token() {
return true === self::$is_refresh_token ? true : false;
}
Expand Down Expand Up @@ -432,7 +458,7 @@ public static function revoke_user_secret( int $user_id ) {
/**
* Filter the capability that is tied to editing/viewing user JWT Auth info
*
* @param string 'edit_users'
* @param string 'edit_users'
* @param int $user_id
*/
$capability = apply_filters( 'graphql_jwt_auth_edit_users_capability', 'edit_users', $user_id );
Expand Down Expand Up @@ -476,7 +502,7 @@ public static function unrevoke_user_secret( int $user_id ) {
/**
* Filter the capability that is tied to editing/viewing user JWT Auth info
*
* @param string 'edit_users'
* @param string 'edit_users'
* @param int $user_id
*/
$capability = apply_filters( 'graphql_jwt_auth_edit_users_capability', 'edit_users', $user_id );
Expand Down Expand Up @@ -505,9 +531,9 @@ public static function unrevoke_user_secret( int $user_id ) {


protected static function set_status( $status_code ) {
add_filter( 'graphql_response_status_code', function() use ( $status_code ) {
add_filter( 'graphql_response_status_code', function () use ( $status_code ) {
return $status_code;
});
} );
}

/**
Expand All @@ -516,8 +542,8 @@ protected static function set_status( $status_code ) {
*
* @param string $token The encoded JWT Token
*
* @throws \Exception
* @return mixed|boolean|string
* @throws \Exception
*/
public static function validate_token( $token = null, $refresh = false ) {

Expand Down Expand Up @@ -571,7 +597,7 @@ public static function validate_token( $token = null, $refresh = false ) {
JWT::$leeway = 60;

$secret = self::get_secret_key();
$token = ! empty( $token ) ? JWT::decode( $token, $secret, [ 'HS256' ] ) : null;
$token = ! empty( $token ) ? JWT::decode( $token, $secret, [ 'HS256' ] ) : null;

/**
* The Token is decoded now validate the iss
Expand All @@ -588,21 +614,31 @@ public static function validate_token( $token = null, $refresh = false ) {
}

/**
* If there is a user_secret in the token (refresh tokens) make sure it matches what
* If there is a user_secret in the token make sure it is not revoked and still valid.
*/
if ( isset( $token->data->user->user_secret ) ) {

if ( Auth::is_jwt_secret_revoked( $token->data->user->id ) ) {
throw new \Exception( __( 'The User Secret does not match or has been revoked for this user', 'wp-graphql-jwt-authentication' ) );
throw new \Exception( __( 'The User Secret has been revoked for this user', 'wp-graphql-jwt-authentication' ) );
}

$token_user_secret = $token->data->user->user_secret;
$user_secret = get_user_meta( $token->data->user->id, 'graphql_jwt_auth_secret', true );

if ( $token_user_secret !== $user_secret ) {
throw new \Exception( __( 'The User Secret does not match for this user', 'wp-graphql-jwt-authentication' ) );
}
} else if (self::$auth_token_is_stateful || self::$is_refresh_token) {
throw new \Exception( __( 'The User Secret is missing in the token.', 'wp-graphql-jwt-authentication' ) );
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comment as above. If the secret exists for both auth tokens and refresh tokens, the tokens are the same 🤔

}

/**
* If any exceptions are caught
*/
} catch ( \Exception $error ) {
self::set_status( 403 );
return new \WP_Error( 'invalid_token', __( 'The JWT Token is invalid', 'wp-graphql-jwt-authentication' ) );

return new UserError( __( 'The JWT Token is invalid: ' . $error, 'wp-graphql-jwt-authentication' ) );
}

self::$is_refresh_token = false;
Expand Down
4 changes: 3 additions & 1 deletion tests/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
acceptance.suite.yml
functional.suite.yml
wpunit.suite.yml
unit.suite.yml
unit.suite.yml
*
!.gitignore
Loading