usefulteam / jwt-auth

WordPress JSON Web Token Authentication
https://wordpress.org/plugins/jwt-auth/
122 stars 49 forks source link

Give refresh_token in JSON response #115

Open portalnorge opened 6 months ago

portalnorge commented 6 months ago

To be able to refresh the authToken in my flutterflow-app I need to access the refresh_token in another state than in a cookie. So I've updated class-auth.php to handle both the current cookie-solution as well as a JSON response if cookie is not present.

<?php
/**
 * Setup JWT-Auth.
 *
 * @package jwt-auth
 */

namespace JWTAuth;

use Exception;

use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

/**
 * The public-facing functionality of the plugin.
 */
class Auth {
    /**
     * The namespace to add to the api calls.
     *
     * @var string The namespace to add to the api call
     */
    private $namespace;

    /**
     * Store errors to display if the JWT is wrong
     *
     * @var WP_REST_Response
     */
    private $jwt_error = null;

    /**
     * Collection of translate-able messages.
     *
     * @var array
     */
    private $messages = array();

    /**
     * The REST API slug.
     *
     * @var string
     */
    private $rest_api_slug = 'wp-json';

    /**
     * Setup action & filter hooks.
     */
    public function __construct() {
        $this->namespace = 'jwt-auth/v1';

        $this->messages = array(
            'jwt_auth_no_auth_header' => __('Authorization header not found.', 'jwt-auth') ,
            'jwt_auth_bad_auth_header' => __('Authorization header malformed.', 'jwt-auth') ,
        );
    }

    /**
     * Add the endpoints to the API
     */
    public function register_rest_routes() {
        register_rest_route($this->namespace, 'token', array(
            'methods' => 'POST',
            'callback' => array(
                $this,
                'get_token'
            ) ,
            'permission_callback' => '__return_true',
        ));

        register_rest_route($this->namespace, 'token/validate', array(
            'methods' => 'POST',
            'callback' => array(
                $this,
                'validate_token'
            ) ,
            'permission_callback' => '__return_true',
        ));

        register_rest_route($this->namespace, 'token/refresh', array(
            'methods' => 'POST',
            'callback' => array(
                $this,
                'refresh_token'
            ) ,
            'permission_callback' => '__return_true',
        ));
    }

    /**
     * Add CORs suppot to the request.
     */
    public function add_cors_support() {
        $enable_cors = defined('JWT_AUTH_CORS_ENABLE') ? JWT_AUTH_CORS_ENABLE : false;

        if ($enable_cors && !headers_sent()) {
            $headers = apply_filters('jwt_auth_cors_allow_headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization, Cookie');

            header(sprintf('Access-Control-Allow-Headers: %s', $headers));
        }
    }

    /**
     * Authenticate user either via wp_authenticate or custom auth (e.g: OTP).
     *
     * @param string $username The username.
     * @param string $password The password.
     * @param mixed  $custom_auth The custom auth data (if any).
     *
     * @return WP_User|WP_Error $user Returns WP_User object if success, or WP_Error if failed.
     */
    public function authenticate_user($username, $password, $custom_auth = '') {
        // If using custom authentication.
        if ($custom_auth) {
            $custom_auth_error = new WP_Error('jwt_auth_custom_auth_failed', __('Custom authentication failed.', 'jwt-auth'));

            /**
             * Do your own custom authentication and return the result through this filter.
             * It should return either WP_User or WP_Error.
             */
            $user = apply_filters('jwt_auth_do_custom_auth', $custom_auth_error, $username, $password, $custom_auth);
        }
        else {
            $user = wp_authenticate($username, $password);
        }

        return $user;
    }

    /**
     * Get token by sending POST request to jwt-auth/v1/token.
     *
     * @param WP_REST_Request $request The request.
     * @return WP_REST_Response The response.
     */
    public function get_token(WP_REST_Request $request) {
        $secret_key = defined('JWT_AUTH_SECRET_KEY') ? JWT_AUTH_SECRET_KEY : false;

        $username = $request->get_param('username');
        $password = $request->get_param('password');
        $custom_auth = $request->get_param('custom_auth');
        $refresh_token_from_body = $request->get_param('refresh_token');

        // Check the secret key.
        if (!$secret_key) {
            return new WP_REST_Response(['success' => false, 'statusCode' => 500, 'code' => 'jwt_auth_bad_config', 'message' => __('JWT is not configured properly.', 'jwt-auth') , 'data' => [], ], 500);
        }

        // Try to authenticate using the refresh token from cookie or body
        $refresh_token = isset($_COOKIE['refresh_token']) ? $_COOKIE['refresh_token'] : $refresh_token_from_body;

        if (!empty($refresh_token)) {
            $device = $request->get_param('device') ? : '';
            $user_id = $this->validate_refresh_token($refresh_token, $device);

            if ($user_id instanceof WP_REST_Response) {
                return $user_id;
            }
            elseif (is_numeric($user_id)) {
                $user = get_user_by('id', $user_id);
            }
        }
        else {
            $user = $this->authenticate_user($username, $password, $custom_auth);
        }

        if (is_wp_error($user)) {
            $error_code = $user->get_error_code();
            return new WP_REST_Response(['success' => false, 'statusCode' => 401, 'code' => $error_code, 'message' => strip_tags($user->get_error_message($error_code)) , 'data' => [], ], 401);
        }

        // Generate the JWT token for the authenticated user.
        $response = $this->generate_token($user, false); // This is assumed to return an array.
        // Ensure that 'data' key exists and is an array.
        if (!isset($response['data']) || !is_array($response['data'])) {
            $response['data'] = [];
        }

        // If username and password are provided, proceed to include refresh token in response.
        if (!empty($username) && !empty($password)) {
            $refreshTokenDetails = $this->send_refresh_token($user, $request);
            $response['data']['refresh_token'] = $refreshTokenDetails['refresh_token'];
            $response['data']['refresh_token_expires'] = $refreshTokenDetails['expires'];
        }

        return new WP_REST_Response($response, 200);
    }

    /**
     * Generate access token.
     *
     * @param WP_User $user The WP_User object.
     * @param bool    $return_raw Whether or not to return as raw token string.
     *
     * @return WP_REST_Response|string Return as raw token string or as a formatted WP_REST_Response.
     */
    public function generate_token($user, $return_raw = true) {
        $secret_key = defined('JWT_AUTH_SECRET_KEY') ? JWT_AUTH_SECRET_KEY : false;
        $issued_at = time();
        $not_before = $issued_at;
        $not_before = apply_filters('jwt_auth_not_before', $not_before, $issued_at);
        $expire = $issued_at + (MINUTE_IN_SECONDS * 10);
        $expire = apply_filters('jwt_auth_expire', $expire, $issued_at);

        $payload = array(
            'iss' => $this->get_iss() ,
            'iat' => $issued_at,
            'nbf' => $not_before,
            'exp' => $expire,
            'data' => array(
                'user' => array(
                    'id' => $user->ID,
                ) ,
            ) ,
        );

        $alg = $this->get_alg();

        // Let the user modify the token data before the sign.
        $token = JWT::encode(apply_filters('jwt_auth_payload', $payload, $user) , $secret_key, $alg);

        // If return as raw token string.
        if ($return_raw) {
            return $token;
        }

        // The token is signed, now create object with basic info of the user.
        $response = array(
            'success' => true,
            'statusCode' => 200,
            'code' => 'jwt_auth_valid_credential',
            'message' => __('Credential is valid', 'jwt-auth') ,
            'data' => array(
                'token' => $token,
                'id' => $user->ID,
                'email' => $user->user_email,
                'nicename' => $user->user_nicename,
                'firstName' => $user->first_name,
                'lastName' => $user->last_name,
                'displayName' => $user->display_name,
            ) ,
        );

        // Let the user modify the data before send it back.
        return apply_filters('jwt_auth_valid_credential_response', $response, $user);
    }

    /**
     * Sends a new refresh token.
     *
     * @param \WP_User $user The WP_User object.
     * @param \WP_REST_Request $request The request.
     *
     * @return void
     */
    public function send_refresh_token(\WP_User $user, WP_REST_Request $request) {
        $refresh_token = bin2hex(random_bytes(32));
        $created = time();
        $expires = $created + DAY_IN_SECONDS * 30;
        $expires = apply_filters('jwt_auth_refresh_expire', $expires, $created);
        $prefixed_refresh_token = $user->ID . '.' . $refresh_token;

        // Initialize or retrieve existing refresh tokens data
        $user_refresh_tokens = get_user_meta($user->ID, 'jwt_auth_refresh_tokens', true);
        if (!is_array($user_refresh_tokens)) {
            $user_refresh_tokens = [];
        }

        // Update or set the refresh token for the device
        $device = $request->get_param('device') ? : 'default'; // Default device if none specified
        $user_refresh_tokens[$device] = ['token' => $prefixed_refresh_token, 'expires' => $expires, ];

        // Save the updated tokens back to the user meta
        update_user_meta($user->ID, 'jwt_auth_refresh_tokens', $user_refresh_tokens);

        // Optional: Update the next expiry date for a potential cleanup task
        $expires_next = min(array_column($user_refresh_tokens, 'expires'));
        update_user_meta($user->ID, 'jwt_auth_refresh_tokens_expires_next', $expires_next);

        // Set the refresh token as a secure, HttpOnly cookie (optional, based on use case)
        setcookie('refresh_token', $user->ID . '.' . $refresh_token, $expires, COOKIEPATH, COOKIE_DOMAIN, is_ssl() , true);

        // Return the refresh token details
        return ['refresh_token' => $prefixed_refresh_token, 'expires' => $expires];
    }

    /**
     * Get the token issuer.
     *
     * @return string The token issuer (iss).
     */
    public function get_iss() {
        return apply_filters('jwt_auth_iss', get_bloginfo('url'));
    }

    /**
     * Get the supported jwt auth signing algorithm.
     *
     * @see https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40
     *
     * @return string $alg
     */
    public function get_alg() {
        return apply_filters('jwt_auth_alg', 'HS256');
    }

    /**
     * Determine if given response is an error response.
     *
     * @param mixed $response The response.
     * @return boolean
     */
    public function is_error_response($response) {
        if (!empty($response) && property_exists($response, 'data') && is_array($response->data)) {
            if (!isset($response->data['success']) || !$response->data['success']) {
                return true;
            }
        }

        return false;
    }

    /**
     * Public token validation function based on Authorization header.
     *
     * @param bool $return_response Either to return full WP_REST_Response or to return the payload only.
     *
     * @return WP_REST_Response | Array Returns WP_REST_Response or token's $payload.
     */
    public function validate_token($return_response = true) {
        /**
         * Looking for the HTTP_AUTHORIZATION header, if not present just
         * return the user.
         */
        $headerkey = apply_filters('jwt_auth_authorization_header', 'HTTP_AUTHORIZATION');
        $auth = isset($_SERVER[$headerkey]) ? $_SERVER[$headerkey] : false;

        // Double check for different auth header string (server dependent).
        if (!$auth) {
            $auth = isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION']) ? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] : false;
        }

        if (!$auth) {
            return new WP_REST_Response(array(
                'success' => false,
                'statusCode' => 401,
                'code' => 'jwt_auth_no_auth_header',
                'message' => $this->messages['jwt_auth_no_auth_header'],
                'data' => array() ,
            ) , 401);
        }

        /**
         * The HTTP_AUTHORIZATION is present, verify the format.
         * If the format is wrong return the user.
         */
        list($token) = sscanf($auth, 'Bearer %s');

        if (!$token) {
            return new WP_REST_Response(array(
                'success' => false,
                'statusCode' => 401,
                'code' => 'jwt_auth_bad_auth_header',
                'message' => $this->messages['jwt_auth_bad_auth_header'],
                'data' => array() ,
            ) , 401);
        }

        // Get the Secret Key.
        $secret_key = defined('JWT_AUTH_SECRET_KEY') ? JWT_AUTH_SECRET_KEY : false;

        if (!$secret_key) {
            return new WP_REST_Response(array(
                'success' => false,
                'statusCode' => 401,
                'code' => 'jwt_auth_bad_config',
                'message' => __('JWT is not configured properly.', 'jwt-auth') ,
                'data' => array() ,
            ) , 401);
        }

        // Try to decode the token.
        try {
            $alg = $this->get_alg();
            $payload = JWT::decode($token, new Key($secret_key, $alg));

            // The Token is decoded now validate the iss.
            if ($payload->iss !== $this->get_iss()) {
                // The iss do not match, return error.
                return new WP_REST_Response(array(
                    'success' => false,
                    'statusCode' => 401,
                    'code' => 'jwt_auth_bad_iss',
                    'message' => __('The iss do not match with this server.', 'jwt-auth') ,
                    'data' => array() ,
                ) , 401);
            }

            // Check the user id existence in the token.
            if (!isset($payload->data->user->id)) {
                // No user id in the token, abort!!
                return new WP_REST_Response(array(
                    'success' => false,
                    'statusCode' => 401,
                    'code' => 'jwt_auth_bad_request',
                    'message' => __('User ID not found in the token.', 'jwt-auth') ,
                    'data' => array() ,
                ) , 401);
            }

            // So far so good, check if the given user id exists in db.
            $user = get_user_by('id', $payload->data->user->id);

            if (!$user) {
                // No user id in the token, abort!!
                return new WP_REST_Response(array(
                    'success' => false,
                    'statusCode' => 401,
                    'code' => 'jwt_auth_user_not_found',
                    'message' => __("User doesn't exist", 'jwt-auth') ,
                    'data' => array() ,
                ) , 401);
            }

            // Check extra condition if exists.
            $failed_msg = apply_filters('jwt_auth_extra_token_check', '', $user, $token, $payload);

            if (!empty($failed_msg)) {
                // No user id in the token, abort!!
                return new WP_REST_Response(array(
                    'success' => false,
                    'statusCode' => 401,
                    'code' => 'jwt_auth_obsolete_token',
                    'message' => __('Token is obsolete', 'jwt-auth') ,
                    'data' => array() ,
                ) , 401);
            }

            // Everything looks good, return the payload if $return_response is set to false.
            if (!$return_response) {
                return $payload;
            }

            $response = array(
                'success' => true,
                'statusCode' => 200,
                'code' => 'jwt_auth_valid_token',
                'message' => __('Token is valid', 'jwt-auth') ,
                'data' => array() ,
            );

            $response = apply_filters('jwt_auth_valid_token_response', $response, $user, $token, $payload);

            // Otherwise, return success response.
            return new WP_REST_Response($response);
        }
        catch(Exception $e) {
            // Something is wrong when trying to decode the token, return error response.
            return new WP_REST_Response(array(
                'success' => false,
                'statusCode' => 401,
                'code' => 'jwt_auth_invalid_token',
                'message' => $e->getMessage() ,
                'data' => array() ,
            ) , 401);
        }
    }

    /**
     * Validates refresh token and generates a new refresh token.
     *
     * @param WP_REST_Request $request The request.
     * @return WP_REST_Response Returns WP_REST_Response.
     */
    public function refresh_token(WP_REST_Request $request) {
        // First try to get the refresh token from the cookie
        $refresh_token_from_cookie = isset($_COOKIE['refresh_token']) ? $_COOKIE['refresh_token'] : '';

        // If not found in cookie, try to get it from the request body
        $refresh_token = !empty($refresh_token_from_cookie) ? $refresh_token_from_cookie : $request->get_param('refresh_token');

        if (empty($refresh_token)) {
            return new WP_REST_Response(array(
                'success' => false,
                'statusCode' => 401,
                'code' => 'jwt_auth_no_refresh_token',
                'message' => __('Refresh token not found.', 'jwt-auth') ,
            ) , 401);
        }

        $device = $request->get_param('device') ? : '';
        $user_id = $this->validate_refresh_token($refresh_token, $device);

        if ($user_id instanceof WP_REST_Response) {
            return $user_id;
        }

        // Generate a new access token and refresh token.
        $user = get_user_by('id', $user_id);
        $response = $this->generate_token($user, false); // Assuming this will generate the access token
        $refreshTokenDetails = $this->send_refresh_token($user, $request);
        $response['data']['refresh_token'] = $refreshTokenDetails['refresh_token'];
        $response['data']['refresh_token_expires'] = $refreshTokenDetails['expires'];

        return new WP_REST_Response($response, 200);
    }

    /**
     * Validates refresh token.
     *
     * @param string $refresh_token_cookie The refresh token to validate.
     * @param string $device The device of the refresh token.
     * @return int|WP_REST_Response Returns user ID if valid or WP_REST_Response on error.
     */
    public function validate_refresh_token($refresh_token, $device) {
        $parts = explode('.', $refresh_token);
        if (count($parts) < 2) {
            // Token format is invalid
            return new WP_REST_Response(['success' => false, 'statusCode' => 401, 'code' => 'jwt_auth_invalid_refresh_token', 'message' => __('Invalid refresh token format.', 'jwt-auth') , ], 401);
        }

        // Extract the user ID and token part
        $user_id = array_shift($parts); // Remove the first part as the user ID
        $token_part = implode('.', $parts); // Reassemble the remaining parts as the token
        // Retrieve stored tokens
        $user_refresh_tokens = get_user_meta($user_id, 'jwt_auth_refresh_tokens', true);

        if (!isset($user_refresh_tokens[$device]) || $user_refresh_tokens[$device]['token'] !== $refresh_token || $user_refresh_tokens[$device]['expires'] < time()) {
            // Token is invalid or expired
            return new WP_REST_Response(['success' => false, 'statusCode' => 401, 'code' => 'jwt_auth_invalid_refresh_token', 'message' => __('Invalid or expired refresh token.', 'jwt-auth') , ], 401);
        }

        // Token is valid
        return $user_id;
    }

    /**
     * This is our Middleware to try to authenticate the user according to the token sent.
     *
     * @param int|bool $user_id User ID if one has been determined, false otherwise.
     * @return int|bool User ID if one has been determined, false otherwise.
     */
    public function determine_current_user($user_id) {
        /**
         * This hook only should run on the REST API requests to determine
         * if the user in the Token (if any) is valid, for any other
         * normal call ex. wp-admin/.* return the user.
         *
         * @since 1.2.3
         */
        $this->rest_api_slug = get_option('permalink_structure') ? rest_get_url_prefix() : '?rest_route=/';

        $valid_api_uri = strpos($_SERVER['REQUEST_URI'], $this->rest_api_slug);

        // Skip validation if not a REST API request or a user was determined already.
        if (!$valid_api_uri || $user_id) {
            return $user_id;
        }

        /**
         * If the request URI is for validate the token don't do anything,
         * This avoid double calls to the validate_token function.
         */
        $validate_uri = strpos($_SERVER['REQUEST_URI'], 'token/validate');

        if ($validate_uri > 0) {
            return $user_id;
        }

        $payload = $this->validate_token(false);

        // If $payload is an error response, then the client did not send a token,
        // or the token is invalid, the client uses a different way to authenticate,
        // or the endpoint does not require authentication.
        // Let the endpoint do its regular access checks.
        if ($this->is_error_response($payload)) {
            return $user_id;
        }

        // Everything is ok here, return the user ID stored in the token.
        return $payload->data->user->id;
    }

    /**
     * Filter to hook the rest_pre_dispatch, if there is an error in the request
     * send it, if there is no error just continue with the current request.
     *
     * @param mixed           $result Can be anything a normal endpoint can return, or null to not hijack the request.
     * @param WP_REST_Server  $server Server instance.
     * @param WP_REST_Request $request The request.
     *
     * @return mixed $result
     */
    public function rest_pre_dispatch($result, WP_REST_Server $server, WP_REST_Request $request) {
        if ($this->is_error_response($this->jwt_error)) {
            return $this->jwt_error;
        }

        if (empty($result)) {
            return $result;
        }

        return $result;
    }
}
marchrius commented 6 months ago

Hi @portalnorge, we encountered a similar issue with our React Native mobile app while using the REST API, and we ended up implementing a solution based on a different flow type managed by WordPress.

If you're interested, feel free to check out #116.