From c1944b7d3b90c389089b7eb713f8dafc9e60e30f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 12:51:11 +0600 Subject: [PATCH 1/8] feat(auth): add REST API routes for login, refresh, and logout - Introduced new REST API routes for user authentication: `/auth/login`, `/auth/refresh`, and `/auth/logout`. - Updated permission callbacks for various existing routes to use more specific permission checks. - Enhanced author detail retrieval to include user metadata conditionally based on permissions. - Improved response handling in course and quiz endpoints to ensure proper data structure and access control. --- classes/RestAPI.php | 47 +- restapi/REST_Author.php | 44 +- restapi/REST_Course.php | 32 +- restapi/REST_Quiz.php | 59 +- restapi/RestAuth.php | 1347 +++++++++++++++++++++++++++++++++++---- 5 files changed, 1352 insertions(+), 177 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index 738bd02561..fe7bf3532a 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -232,6 +232,37 @@ private function loader( $class_name ) { * @return void */ public function init_routes() { + // Auth: login / refresh / logout. + register_rest_route( + $this->namespace, + '/auth/login', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_login' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + + register_rest_route( + $this->namespace, + '/auth/refresh', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_refresh' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + + register_rest_route( + $this->namespace, + '/auth/logout', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_logout' ), + 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + ) + ); + // Courses. register_rest_route( $this->namespace, @@ -284,7 +315,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_topics' ), ) ); @@ -305,7 +336,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) ); @@ -326,7 +357,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) ); @@ -347,7 +378,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_by_topic' ), ) ); @@ -368,7 +399,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); @@ -389,7 +420,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); @@ -410,7 +441,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_quiz' ), ) ); @@ -473,7 +504,7 @@ public function init_routes() { }, ), ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => array( RestAuth::class, 'permission_course_content' ), ) ); } diff --git a/restapi/REST_Author.php b/restapi/REST_Author.php index 5385c741af..7f7b844b8e 100644 --- a/restapi/REST_Author.php +++ b/restapi/REST_Author.php @@ -41,36 +41,40 @@ class REST_Author { * @return mixed */ public function author_detail( WP_REST_Request $request ) { - $this->user_id = $request->get_param( 'id' ); + $this->user_id = absint( $request->get_param( 'id' ) ); $user_data = get_userdata( $this->user_id ); - // Author object. - $author = is_a( $user_data, 'WP_User' ) ? $user_data->data : false; - - if ( $author ) { - // Unset user pass & key. - unset( $author->user_pass ); - unset( $author->user_activation_key ); - - // Get author course ID. - $author->courses = get_user_meta( $this->user_id, '_tutor_instructor_course_id', false ); - + if ( ! is_a( $user_data, 'WP_User' ) ) { $response = array( - 'code' => 'success', - 'message' => __( 'Author details retrieved successfully', 'tutor' ), - 'data' => $author, + 'code' => 'invalid_id', + 'message' => __( 'Author not found', 'tutor' ), + 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); + } + + $author = (object) array( + 'ID' => $user_data->ID, + 'display_name' => $user_data->display_name, + 'user_nicename' => $user_data->user_nicename, + 'courses' => get_user_meta( $this->user_id, '_tutor_instructor_course_id', false ), + ); + + if ( RestAuth::can_view_user_private_fields( $this->user_id ) ) { + $author->user_login = $user_data->user_login; + $author->user_email = $user_data->user_email; + $author->user_registered = $user_data->user_registered; + $author->user_url = $user_data->user_url; } $response = array( - 'code' => 'invalid_id', - 'message' => __( 'Author not found', 'tutor' ), - 'data' => array(), + 'code' => 'success', + 'message' => __( 'Author details retrieved successfully', 'tutor' ), + 'data' => $author, ); - return self::send( $response ); + return static::send( $response ); } } diff --git a/restapi/REST_Course.php b/restapi/REST_Course.php index ca407a49ef..c42c659a41 100644 --- a/restapi/REST_Course.php +++ b/restapi/REST_Course.php @@ -154,12 +154,22 @@ function ( $post ) { $author = get_userdata( $post->post_author ); if ( $author ) { - // Unset user pass & key. - unset( $author->data->user_pass ); - unset( $author->data->user_activation_key ); - } + $author_payload = (object) array( + 'ID' => $author->ID, + 'display_name' => $author->display_name, + 'user_nicename' => $author->user_nicename, + ); + + if ( RestAuth::can_view_user_private_fields( (int) $author->ID ) ) { + $author_payload->user_login = $author->user_login; + $author_payload->user_email = $author->user_email; + $author_payload->user_registered = $author->user_registered; + } - is_a( $author, 'WP_User' ) ? $post->post_author = $author->data : new \stdClass(); + $post->post_author = $author_payload; + } else { + $post->post_author = new \stdClass(); + } $thumbnail_size = apply_filters( 'tutor_rest_course_thumbnail_size', 'post-thumbnail' ); $post->thumbnail_url = get_the_post_thumbnail_url( $post->ID, $thumbnail_size ); @@ -185,7 +195,7 @@ function ( $post ) { 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -194,7 +204,7 @@ function ( $post ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -216,7 +226,7 @@ public function course_detail( WP_REST_Request $request ) { 'message' => __( 'Course detail retrieved successfully', 'tutor' ), 'data' => $detail, ); - return self::send( $response ); + return static::send( $response ); } $response = array( 'code' => 'course_detail', @@ -224,7 +234,7 @@ public function course_detail( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -330,7 +340,7 @@ public function course_contents( WP_REST_Request $request ) { 'message' => __( 'Course contents retrieved successfully', 'tutor' ), 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -339,6 +349,6 @@ public function course_contents( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } } diff --git a/restapi/REST_Quiz.php b/restapi/REST_Quiz.php index 0293dd8c8e..f1d8e363e2 100644 --- a/restapi/REST_Quiz.php +++ b/restapi/REST_Quiz.php @@ -106,7 +106,7 @@ public function get_quiz( WP_REST_Request $request ) { 'message' => __( 'Quiz not found for given ID', 'tutor' ), 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } $quiz->quiz_settings = get_post_meta( $quiz->ID, 'tutor_quiz_option', false ); @@ -133,6 +133,10 @@ public function get_quiz( WP_REST_Request $request ) { $question->question_answers = QuizModel::get_question_answers( $question->question_id, $question->question_type ); } + if ( ! RestAuth::can_reveal_quiz_answers( $quiz_id ) ) { + $questions = static::strip_is_correct_from_questions( $questions ); + } + $quiz->quiz_questions = $questions; $response = array( @@ -141,7 +145,7 @@ public function get_quiz( WP_REST_Request $request ) { 'data' => $quiz, ); - return self::send( $response ); + return static::send( $response ); } /** @@ -188,7 +192,7 @@ public function quiz_with_settings( WP_REST_Request $request ) { 'data' => $data, ); } - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -196,7 +200,7 @@ public function quiz_with_settings( WP_REST_Request $request ) { 'message' => __( 'Quiz not found for given ID', 'tutor' ), 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } /** @@ -256,13 +260,17 @@ public function quiz_question_ans( WP_REST_Request $request ) { array_push( $data, $quiz ); } + if ( ! RestAuth::can_reveal_quiz_answers( (int) $this->post_parent ) ) { + $data = static::strip_is_correct_from_questions( $data ); + } + $response = array( 'code' => 'success', 'message' => __( 'Question retrieved successfully', 'tutor' ), 'data' => $data, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -271,7 +279,7 @@ public function quiz_question_ans( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -312,8 +320,17 @@ public function quiz_attempt_details( WP_REST_Request $request ) { ); if ( count( $attempts ) > 0 ) { + $user_id = get_current_user_id(); + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + $can_view_all = $course_id && tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + // unserialize each attempt info. foreach ( $attempts as $key => $attempt ) { + if ( ! $can_view_all && (int) $attempt->user_id !== (int) $user_id ) { + unset( $attempts[ $key ] ); + continue; + } + $attempt->attempt_info = maybe_unserialize( $attempt->attempt_info ); // attach attempt ans. $answers = $this->get_quiz_attempt_ans( $quiz_id ); @@ -325,13 +342,15 @@ public function quiz_attempt_details( WP_REST_Request $request ) { } } + $attempts = array_values( $attempts ); + $response = array( 'code' => 'success', 'message' => __( 'Quiz attempts retrieved successfully', 'tutor' ), 'data' => $attempts, ); - return self::send( $response ); + return static::send( $response ); } $response = array( @@ -340,7 +359,7 @@ public function quiz_attempt_details( WP_REST_Request $request ) { 'data' => array(), ); - return self::send( $response ); + return static::send( $response ); } /** @@ -421,4 +440,28 @@ protected function answer_titles_by_id( $id ) { return $results; } + + /** + * Strip is_correct from question answer options. + * + * @since 4.0.10 + * + * @param array $questions questions with answers. + * + * @return array + */ + private static function strip_is_correct_from_questions( $questions ) { + foreach ( $questions as $question ) { + if ( empty( $question->question_answers ) || ! is_array( $question->question_answers ) ) { + continue; + } + foreach ( $question->question_answers as $answer ) { + if ( is_object( $answer ) && isset( $answer->is_correct ) ) { + unset( $answer->is_correct ); + } + } + } + + return $questions; + } } diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index f36d41bec8..018811919d 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -13,6 +13,9 @@ namespace TUTOR; use Tutor\Helpers\QueryHelper; +use Tutor\Models\EnrollmentModel; +use Tutor\Models\QuizModel; +use WP_REST_Request; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -67,6 +70,55 @@ class RestAuth { */ const KEYS_USER_META_KEY = 'tutor-api-key-secret'; + /** + * Usermeta: refresh token hashes. + * + * @var string + */ + const REFRESH_META_KEY = 'tutor_api_refresh_tokens'; + + /** + * Usermeta: access token version (invalidates JWTs). + * + * @var string + */ + const TOKEN_VERSION_META = 'tutor_api_token_version'; + + /** + * Option for JWT HMAC secret override. + * + * @var string + */ + const JWT_SECRET_OPTION = 'tutor_rest_jwt_secret'; + + /** + * Access JWT lifetime in seconds (~10 minutes). + * + * @var int + */ + const ACCESS_TTL = 600; + + /** + * Refresh token lifetime in seconds (30 days). + * + * @var int + */ + const REFRESH_TTL = 2592000; + + /** + * Max failed login attempts before rate limit. + * + * @var int + */ + const LOGIN_MAX_ATTEMPTS = 5; + + /** + * Login rate-limit window in seconds. + * + * @var int + */ + const LOGIN_WINDOW = 900; + /** * Register hooks. * @@ -79,57 +131,43 @@ public function __construct() { add_action( 'wp_ajax_tutor_update_api_permission', __CLASS__ . '::update_api_permission' ); add_action( 'wp_ajax_tutor_revoke_api_keys', __CLASS__ . '::revoke_api_keys' ); add_filter( 'determine_current_user', array( $this, 'api_auth' ) ); + add_action( 'profile_update', array( $this, 'maybe_invalidate_tokens_on_profile_update' ), 10, 2 ); + add_action( 'after_password_reset', array( $this, 'invalidate_user_tokens' ), 10, 1 ); + add_action( 'password_reset', array( $this, 'invalidate_user_tokens' ), 10, 1 ); + add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'enforce_actor_identity' ), 20, 3 ); } /** - * API auth. + * Authenticate Tutor REST requests from access JWT only. * * @since 2.7.1 - * @since 4.0.8 Only authenticate on real Tutor REST paths, and only when the - * API key permission is All (full identity must not be granted - * to Read/Write-scoped keys via determine_current_user). + * @since 4.0.8 Identity is never taken from the API key owner. * * @param int|false $user_id user id. * * @return int|false */ public function api_auth( $user_id ) { - // Don't authenticate twice. - if ( ! empty( $user_id ) || ! self::is_tutor_api_request() ) { - return $user_id; - } - - if ( ! wp_is_application_passwords_available() ) { - return $user_id; - } - - if ( ! isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { + if ( ! empty( $user_id ) || ! static::is_tutor_api_request() ) { return $user_id; } - $api_key = sanitize_key( $_SERVER['PHP_AUTH_USER'] ) ?? ''; - $api_secret = sanitize_key( $_SERVER['PHP_AUTH_PW'] ) ?? ''; - $record = self::validate_api_key_secret( $api_key, $api_secret, true ); - - if ( ! $record ) { + $token = static::get_access_token_from_request(); + if ( ! $token ) { return $user_id; } - $meta = json_decode( $record->meta_value ); - if ( ! is_object( $meta ) || ! isset( $meta->permission ) || self::ALL !== $meta->permission ) { + $jwt_user_id = static::verify_access_token( $token ); + if ( ! $jwt_user_id ) { return $user_id; } - return (int) $record->user_id; + return $jwt_user_id; } /** * Whether the current request targets a Tutor REST API route. * - * Matches the URL path only (not arbitrary query values), so embedding - * "/wp-json/tutor/" in an unrelated query parameter cannot trigger auth. - * Also accepts the plain-permalink form via the rest_route query var only. - * * @since 2.7.1 * @since 4.0.8 Path-only detection; ignore unrelated query string values. * @@ -145,7 +183,7 @@ public static function is_tutor_api_request() { if ( is_string( $path ) && '' !== $path ) { $path = trailingslashit( $path ); - $rest_prefix = trailingslashit( rest_get_url_prefix() ); // e.g. wp-json/. + $rest_prefix = trailingslashit( rest_get_url_prefix() ); $needle = '/' . $rest_prefix . 'tutor/'; if ( false !== strpos( $path, $needle ) ) { @@ -156,6 +194,39 @@ public static function is_tutor_api_request() { return false; } + /** + * Whether request is a Tutor auth login/refresh/logout route. + * + * @since 4.0.10 + * + * @return bool + */ + public static function is_auth_route() { + if ( empty( $_SERVER['REQUEST_URI'] ) ) { + return false; + } + + $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $path = wp_parse_url( $request_uri, PHP_URL_PATH ); + + if ( ! is_string( $path ) || '' === $path ) { + return false; + } + + $path = trailingslashit( $path ); + $rest_prefix = trailingslashit( rest_get_url_prefix() ); + $base = '/' . $rest_prefix . 'tutor/v1/auth/'; + + return ( + false !== strpos( $path, $base . 'login/' ) + || false !== strpos( $path, $base . 'refresh/' ) + || false !== strpos( $path, $base . 'logout/' ) + || false !== strpos( $path, $base . 'login' ) + || false !== strpos( $path, $base . 'refresh' ) + || false !== strpos( $path, $base . 'logout' ) + ); + } + /** * Generate api keys * @@ -164,10 +235,8 @@ public static function is_tutor_api_request() { * @return void send wp_json response */ public static function generate_api_keys() { - // Validate nonce. tutor_utils()->checking_nonce(); - // Check user permission. if ( ! current_user_can( 'administrator' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -187,23 +256,20 @@ public static function generate_api_keys() { ) ); - // Update user meta. $add = add_user_meta( get_current_user_id(), - self::KEYS_USER_META_KEY, + static::KEYS_USER_META_KEY, $info ); if ( $add ) { - $response = self::prepare_response( $add, $api_key, $api_secret, $permission, $description ); + $response = static::prepare_response( $add, $api_key, $api_secret, $permission, $description ); wp_send_json_success( $response ); } else { wp_send_json_error( tutor_utils()->error_message( '0' ) ); } - } - /** * Update api permission * @@ -214,10 +280,8 @@ public static function generate_api_keys() { public static function update_api_permission() { global $wpdb; - // Validate nonce. tutor_utils()->checking_nonce(); - // Check user permission. if ( ! current_user_can( 'administrator' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -232,15 +296,14 @@ public static function update_api_permission() { $meta_value->permission = $permission; $meta_value->description = $description; - // Update user meta. try { QueryHelper::update( $wpdb->usermeta, - array( 'meta_value' => json_encode( $meta_value ) ), + array( 'meta_value' => wp_json_encode( $meta_value ) ), array( 'umeta_id' => $meta_id ) ); - $response = self::prepare_response( $meta_id, $meta_value->key, $meta_value->secret, $permission, $description ); + $response = static::prepare_response( $meta_id, $meta_value->key, $meta_value->secret, $permission, $description ); wp_send_json_success( $response ); } catch ( \Throwable $th ) { @@ -256,10 +319,8 @@ public static function update_api_permission() { * @return void send wp_json response */ public static function revoke_api_keys() { - // Validate nonce. tutor_utils()->checking_nonce(); - // Check user permission. if ( ! current_user_can( 'administrator' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -270,7 +331,6 @@ public static function revoke_api_keys() { wp_send_json_error( __( 'Invalid meta id', 'tutor' ) ); } - // Delete api keys. global $wpdb; $delete = QueryHelper::delete( $wpdb->usermeta, array( 'umeta_id' => $meta_id ) ); @@ -301,14 +361,14 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r $results = QueryHelper::get_all( $table, - array( 'meta_key' => self::KEYS_USER_META_KEY ), //phpcs:ignore + array( 'meta_key' => static::KEYS_USER_META_KEY ), //phpcs:ignore 'umeta_id' ); if ( is_array( $results ) && count( $results ) ) { foreach ( $results as $result ) { $obj = json_decode( $result->meta_value ); - if ( $obj->key === $api_key && $obj->secret === $api_secret ) { + if ( is_object( $obj ) && isset( $obj->key, $obj->secret ) && $obj->key === $api_key && $obj->secret === $api_secret ) { $valid = true; if ( $return_result ) { return $result; @@ -322,118 +382,1145 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Process api request + * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. * * @since 2.2.1 + * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. * * @return boolean */ public static function process_api_request() { - $headers = apache_request_headers(); + $credentials = static::get_api_credentials_from_request(); + if ( ! $credentials ) { + return false; + } + + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! $record ) { + return false; + } + + $meta = json_decode( $record->meta_value ); + if ( ! is_object( $meta ) || empty( $meta->permission ) ) { + return false; + } + + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; + $permission = $meta->permission; + + // Auth routes may POST with a Read key (login/refresh/logout). + if ( static::is_auth_route() ) { + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + + if ( 'DELETE' === $method ) { + return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + $write_methods = array( 'POST', 'PUT', 'PATCH' ); + if ( in_array( $method, $write_methods, true ) ) { + return in_array( $permission, array( static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Whether the request has a JWT-authenticated WordPress user. + * + * @since 4.0.10 + * + * @return bool + */ + public static function has_authenticated_user() { + return (int) get_current_user_id() > 0; + } + + /** + * Valid API key for this HTTP method and an authenticated end user (JWT). + * + * Used by Tutor Pro REST routes. + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_api_request() { + return static::process_api_request() && static::has_authenticated_user(); + } + + /** + * Whether the current user may act as the given user (self or privileged admin). + * + * @since 4.0.10 + * + * @param int $target_user_id target user id. + * + * @return bool + */ + public static function can_act_as_user( $target_user_id ) { + $current = get_current_user_id(); + $target = absint( $target_user_id ); + + if ( ! $current || ! $target ) { + return false; + } + + if ( $current === $target ) { + return true; + } + + return user_can( $current, 'list_users' ) || user_can( $current, 'manage_options' ); + } + + /** + * Whether the current user may act as a student for a course. + * + * Self, admin, or instructor/admin with course content access. + * + * @since 4.0.10 + * + * @param int $student_id student user id. + * @param int $course_id course id when known. + * + * @return bool + */ + public static function can_act_as_student( $student_id, $course_id = 0 ) { + if ( static::can_act_as_user( $student_id ) ) { + return true; + } + + $current = get_current_user_id(); + $course_id = absint( $course_id ); + if ( ! $current || ! $course_id ) { + return false; + } + + return (bool) tutor_utils()->has_user_course_content_access( $current, $course_id ); + } + + /** + * Prevent client-supplied user IDs from impersonating other users. + * + * Runs for all Tutor REST routes after permission callbacks. Auth login + * routes and unauthenticated requests are skipped. Object-level checks + * can plug in via the `tutor_rest_enforce_object_access` filter. + * + * @since 4.1.0 + * + * @param mixed $response response. + * @param array $handler handler. + * @param WP_REST_Request $request request. + * + * @return mixed|\WP_Error + */ + public static function enforce_actor_identity( $response, $handler, $request ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( ! static::is_tutor_api_request() || static::is_auth_route() ) { + return $response; + } + + if ( ! static::has_authenticated_user() ) { + return $response; + } + + $author_keys = array( 'post_author', 'lesson_author', 'topic_author', 'quiz_author', 'assignment_author' ); + foreach ( $author_keys as $key ) { + if ( null === $request->get_param( $key ) || '' === $request->get_param( $key ) ) { + continue; + } + $requested = absint( $request->get_param( $key ) ); + if ( $requested && ! static::can_act_as_user( $requested ) ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this user.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } - if ( isset( $headers['Authorization'] ) ) { - $authorization_header = $headers['Authorization']; + $course_id = absint( $request->get_param( 'course_id' ) ); - if ( strpos( $authorization_header, 'Basic' ) !== false ) { - $base_64_credentials = str_replace( 'Basic ', '', $authorization_header ); - $credentials = base64_decode( $base_64_credentials ); //phpcs:ignore + if ( null !== $request->get_param( 'student_id' ) && '' !== $request->get_param( 'student_id' ) ) { + $student_id = absint( $request->get_param( 'student_id' ) ); + if ( $student_id && ! static::can_act_as_student( $student_id, $course_id ) ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this student.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } - list($api_key, $api_secret) = explode( ':', $credentials ); + // Enrollment / profile style user_id. + if ( null !== $request->get_param( 'user_id' ) && '' !== $request->get_param( 'user_id' ) ) { + $user_id = absint( $request->get_param( 'user_id' ) ); + if ( $user_id ) { + $allowed = $course_id + ? static::can_act_as_student( $user_id, $course_id ) + : static::can_act_as_user( $user_id ); - if ( self::validate_api_key_secret( $api_key, $api_secret ) ) { - return true; + if ( ! $allowed ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this user.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); } } } - // Key and secret are invalid or not provided. - return false; + /** + * Object-level access for extensions (Tutor Pro ObjectAccess). + * + * @since 4.1.0 + * + * @param true|\WP_Error $result Pass-through true, or WP_Error to deny. + * @param WP_REST_Request $request Request. + * @param array $handler Route handler. + */ + $object_access = apply_filters( 'tutor_rest_enforce_object_access', true, $request, $handler ); + if ( is_wp_error( $object_access ) ) { + return $object_access; + } + + return $response; } /** - * Prepare html response + * Permission: valid API key and may view course learning content. * - * @since 2.2.1 + * @since 4.0.10 * - * @param int $meta_id meta id. - * @param string $key api key. - * @param string $secret api secret. - * @param string $permission authorization permission. - * @param string $description description. + * @param WP_REST_Request $request request. * - * @return string + * @return bool */ - public static function prepare_response( $meta_id, $key, $secret, $permission, $description = '' ) { - $user_id = get_current_user_id(); - ob_start(); - ?> - - - display_name( $user_id ) ); ?> - - - - - - - - - - - - - - - - - - - - -
- - - - -
- - - -
- - -
- - - get_param( 'id' ) ); + if ( ! $course_id ) { + $course_id = absint( $request->get_param( 'course_id' ) ); + } + + return static::can_view_course_content( $course_id ); } /** - * Get available permission + * Permission: topics by course_id. * - * @since 2.2.1 + * @since 4.0.10 * - * @return array + * @param WP_REST_Request $request request. + * + * @return bool */ - public static function available_permissions(): array { - $permissions = array( - array( - 'value' => self::READ, - 'label' => __( 'Read', 'tutor' ), - ), - ); - return apply_filters( 'tutor_rest_api_permissions', $permissions ); + public static function permission_topics( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + return static::can_view_course_content( absint( $request->get_param( 'course_id' ) ) ); + } + + /** + * Permission: lessons or quizzes listed by topic_id. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_by_topic( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + $topic_id = absint( $request->get_param( 'topic_id' ) ); + $course_id = (int) tutor_utils()->get_course_id_by( 'topic', $topic_id ); + + return static::can_view_course_content( $course_id ); + } + + /** + * Permission: quiz by quiz id. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_quiz( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + $quiz_id = absint( $request->get_param( 'id' ) ); + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + + return static::can_view_course_content( $course_id ); + } + + /** + * Whether the user may view full course learning content. + * + * @since 4.0.10 + * + * @param int $course_id course id. + * @param int $user_id user id. + * + * @return bool + */ + public static function can_view_course_content( $course_id, $user_id = 0 ) { + $course_id = absint( $course_id ); + if ( ! $course_id ) { + return false; + } + + if ( Course_List::is_public( $course_id ) ) { + return true; + } + + $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); + if ( ! $user_id ) { + return false; + } + + if ( EnrollmentModel::is_enrolled( $course_id, $user_id ) ) { + return true; + } + + return (bool) tutor_utils()->has_user_course_content_access( $user_id, $course_id ); + } + + /** + * Whether answer keys (is_correct) may be revealed. + * + * @since 4.0.10 + * + * @param int $quiz_id quiz id. + * @param int $user_id user id. + * + * @return bool + */ + public static function can_reveal_quiz_answers( $quiz_id, $user_id = 0 ) { + $quiz_id = absint( $quiz_id ); + $user_id = $user_id ? absint( $user_id ) : get_current_user_id(); + if ( ! $quiz_id || ! $user_id ) { + return false; + } + + $course_id = (int) tutor_utils()->get_course_id_by( 'quiz', $quiz_id ); + if ( $course_id && tutor_utils()->has_user_course_content_access( $user_id, $course_id ) ) { + return true; + } + + $attempt = ( new QuizModel() )->get_quiz_attempt( $quiz_id, $user_id ); + return is_object( $attempt ) && ! empty( $attempt->attempt_ended_at ); + } + + /** + * Whether viewer may see private user fields (email, login, registered). + * + * @since 4.0.10 + * + * @param int $target_user_id target user. + * @param int $viewer_id viewer. + * + * @return bool + */ + public static function can_view_user_private_fields( $target_user_id, $viewer_id = 0 ) { + $target_user_id = absint( $target_user_id ); + $viewer_id = $viewer_id ? absint( $viewer_id ) : get_current_user_id(); + + if ( ! $target_user_id || ! $viewer_id ) { + return false; + } + + if ( $target_user_id === $viewer_id ) { + return true; + } + + if ( user_can( $viewer_id, 'list_users' ) ) { + return true; + } + + $instructor_courses = get_user_meta( $viewer_id, '_tutor_instructor_course_id', false ); + if ( ! is_array( $instructor_courses ) ) { + return false; + } + + foreach ( $instructor_courses as $course_id ) { + $course_id = absint( $course_id ); + if ( $course_id && EnrollmentModel::is_enrolled( $course_id, $target_user_id ) ) { + return true; + } + } + + return false; + } + + /** + * Login — issue access + refresh tokens. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_login( WP_REST_Request $request ) { + $ssl_error = static::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $username = sanitize_text_field( (string) $request->get_param( 'username' ) ); + $password = (string) $request->get_param( 'password' ); + + if ( '' === $username || '' === $password ) { + return new \WP_Error( + 'rest_invalid_credentials', + __( 'Invalid username or password.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + if ( is_email( $username ) ) { + $user_by_email = get_user_by( 'email', $username ); + if ( $user_by_email ) { + $username = $user_by_email->user_login; + } + } + + if ( static::is_login_rate_limited( $username ) ) { + return new \WP_Error( + 'rest_login_limited', + __( 'Too many failed login attempts. Please try again later.', 'tutor' ), + array( 'status' => 429 ) + ); + } + + $user = wp_authenticate( $username, $password ); + if ( is_wp_error( $user ) ) { + static::bump_login_rate_limit( $username ); + return new \WP_Error( + 'rest_invalid_credentials', + __( 'Invalid username or password.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + static::clear_login_rate_limit( $username ); + + return rest_ensure_response( static::build_token_response( (int) $user->ID ) ); + } + + /** + * Refresh access token (rotates refresh token). + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_refresh( WP_REST_Request $request ) { + $ssl_error = static::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); + if ( '' === $refresh ) { + return new \WP_Error( + 'rest_invalid_refresh', + __( 'Invalid refresh token.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + $user_id = static::consume_refresh_token( $refresh ); + if ( ! $user_id ) { + return new \WP_Error( + 'rest_invalid_refresh', + __( 'Invalid refresh token.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + return rest_ensure_response( static::build_token_response( $user_id ) ); + } + + /** + * Logout — delete refresh token(s). + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_logout( WP_REST_Request $request ) { + $ssl_error = static::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $refresh = sanitize_text_field( (string) $request->get_param( 'refresh_token' ) ); + $all = (bool) $request->get_param( 'all' ); + + if ( $all ) { + $token = static::get_access_token_from_request(); + $user_id = $token ? static::verify_access_token( $token ) : 0; + if ( ! $user_id && $refresh ) { + $user_id = static::find_user_id_by_refresh_token( $refresh ); + } + if ( $user_id ) { + static::delete_all_refresh_tokens( $user_id ); + } + } elseif ( $refresh ) { + static::delete_refresh_token( $refresh ); + } + + return rest_ensure_response( + array( + 'success' => true, + ) + ); + } + + /** + * Invalidate tokens when password changes on profile update. + * + * @since 4.0.10 + * + * @param int $user_id user id. + * @param \WP_User $old_user_data old user. + * + * @return void + */ + public function maybe_invalidate_tokens_on_profile_update( $user_id, $old_user_data ) { + $user = get_userdata( $user_id ); + if ( ! $user || ! is_a( $old_user_data, 'WP_User' ) ) { + return; + } + + if ( $user->user_pass !== $old_user_data->user_pass ) { + static::invalidate_user_tokens( $user_id ); + } + } + + /** + * Bump token_version and delete refresh tokens. + * + * @since 4.0.10 + * + * @param int|\WP_User $user user id or object. + * + * @return void + */ + public static function invalidate_user_tokens( $user ) { + $user_id = is_object( $user ) ? (int) $user->ID : absint( $user ); + if ( ! $user_id ) { + return; + } + + $version = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + update_user_meta( $user_id, static::TOKEN_VERSION_META, $version + 1 ); + static::delete_all_refresh_tokens( $user_id ); + } + + /** + * Prepare html response + * + * @since 2.2.1 + * + * @param int $meta_id meta id. + * @param string $key api key. + * @param string $secret api secret. + * @param string $permission authorization permission. + * @param string $description description. + * + * @return string + */ + public static function prepare_response( $meta_id, $key, $secret, $permission, $description = '' ) { + $user_id = get_current_user_id(); + ob_start(); + ?> + + + display_name( $user_id ) ); ?> + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + +
+ + +
+ + + static::READ, + 'label' => __( 'Read', 'tutor' ), + ), + ); + return apply_filters( 'tutor_rest_api_permissions', $permissions ); + } + + /** + * Build login/refresh response payload. + * + * @param int $user_id user id. + * + * @return array + */ + private static function build_token_response( $user_id ) { + $access = static::issue_access_token( $user_id ); + $refresh = static::issue_refresh_token( $user_id ); + $user = get_userdata( $user_id ); + + return array( + 'access_token' => $access['token'], + 'expires_in' => $access['expires_in'], + 'refresh_token' => $refresh, + 'user_id' => $user_id, + 'display_name' => $user ? $user->display_name : '', + ); + } + + /** + * Issue HS256 access JWT. + * + * @param int $user_id user id. + * + * @return array{token:string,expires_in:int} + */ + private static function issue_access_token( $user_id ) { + $now = time(); + $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + + $header = static::base64url_encode( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) ); + $payload = static::base64url_encode( + wp_json_encode( + array( + 'sub' => (int) $user_id, + 'iat' => $now, + 'exp' => $now + static::ACCESS_TTL, + 'iss' => 'tutor', + 'tv' => $tv, + ) + ) + ); + $sig = static::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, static::jwt_secret(), true ) ); + + return array( + 'token' => $header . '.' . $payload . '.' . $sig, + 'expires_in' => static::ACCESS_TTL, + ); + } + + /** + * Verify access JWT. Returns user id or 0. + * + * @param string $jwt token. + * + * @return int + */ + private static function verify_access_token( $jwt ) { + $parts = explode( '.', $jwt ); + if ( 3 !== count( $parts ) ) { + return 0; + } + + list( $header_b64, $payload_b64, $sig_b64 ) = $parts; + + $expected = static::base64url_encode( + hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, static::jwt_secret(), true ) + ); + + if ( ! hash_equals( $expected, $sig_b64 ) ) { + return 0; + } + + $payload_json = static::base64url_decode( $payload_b64 ); + $payload = json_decode( $payload_json ); + if ( ! is_object( $payload ) || empty( $payload->sub ) || empty( $payload->exp ) ) { + return 0; + } + + if ( (int) $payload->exp < time() ) { + return 0; + } + + if ( empty( $payload->iss ) || 'tutor' !== $payload->iss ) { + return 0; + } + + $user_id = (int) $payload->sub; + $user = get_userdata( $user_id ); + if ( ! $user || ! $user->exists() ) { + return 0; + } + + if ( function_exists( 'is_user_spammy' ) && is_user_spammy( $user ) ) { + return 0; + } + + $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + if ( (int) ( $payload->tv ?? -1 ) !== $tv ) { + return 0; + } + + return $user_id; + } + + /** + * JWT HMAC secret. + * + * @return string + */ + private static function jwt_secret() { + $stored = get_option( static::JWT_SECRET_OPTION, '' ); + if ( is_string( $stored ) && strlen( $stored ) >= 32 ) { + return $stored; + } + + try { + $secret = bin2hex( random_bytes( 32 ) ); + } catch ( \Exception $e ) { + $secret = hash_hmac( 'sha256', 'tutor-rest-jwt', wp_salt( 'auth' ) ); + } + + update_option( static::JWT_SECRET_OPTION, $secret, false ); + return $secret; + } + + /** + * Base64 URL encode. + * + * @param string $data raw. + * + * @return string + */ + private static function base64url_encode( $data ) { + return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + + /** + * Base64 URL decode. + * + * @param string $data encoded. + * + * @return string + */ + private static function base64url_decode( $data ) { + $remainder = strlen( $data ) % 4; + if ( $remainder ) { + $data .= str_repeat( '=', 4 - $remainder ); + } + $decoded = base64_decode( strtr( $data, '-_', '+/' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + return false === $decoded ? '' : $decoded; + } + + /** + * Read access token from Authorization Bearer or X-Tutor-User-Token. + * + * @return string + */ + private static function get_access_token_from_request() { + $headers = static::get_request_headers(); + + if ( ! empty( $headers['x-tutor-user-token'] ) ) { + return trim( $headers['x-tutor-user-token'] ); + } + + if ( ! empty( $headers['authorization'] ) && 0 === stripos( $headers['authorization'], 'Bearer ' ) ) { + return trim( substr( $headers['authorization'], 7 ) ); + } + + return ''; + } + + /** + * Read API key/secret from Basic auth or X-Tutor-Api-* headers. + * + * @return array{key:string,secret:string}|null + */ + private static function get_api_credentials_from_request() { + $headers = static::get_request_headers(); + + if ( ! empty( $headers['x-tutor-api-key'] ) && ! empty( $headers['x-tutor-api-secret'] ) ) { + return array( + 'key' => sanitize_text_field( $headers['x-tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['x-tutor-api-secret'] ), + ); + } + + $auth = $headers['authorization'] ?? ''; + if ( $auth && 0 === stripos( $auth, 'Basic ' ) ) { + $decoded = base64_decode( substr( $auth, 6 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + if ( is_string( $decoded ) && false !== strpos( $decoded, ':' ) ) { + list( $key, $secret ) = explode( ':', $decoded, 2 ); + return array( + 'key' => sanitize_text_field( $key ), + 'secret' => sanitize_text_field( $secret ), + ); + } + } + + if ( isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { + return array( + 'key' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) ), + 'secret' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ), + ); + } + + return null; + } + + /** + * Normalized request headers (lowercase keys). + * + * @return array + */ + private static function get_request_headers() { + $headers = array(); + + if ( function_exists( 'apache_request_headers' ) ) { + $raw = apache_request_headers(); + if ( is_array( $raw ) ) { + foreach ( $raw as $key => $value ) { + $headers[ strtolower( $key ) ] = $value; + } + } + } + + foreach ( $_SERVER as $key => $value ) { + if ( 0 === strpos( $key, 'HTTP_' ) ) { + $header_key = strtolower( str_replace( '_', '-', substr( $key, 5 ) ) ); + $headers[ $header_key ] = wp_unslash( $value ); + } + } + + if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) && empty( $headers['authorization'] ) ) { + $headers['authorization'] = wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + } + + return $headers; + } + + /** + * Issue opaque refresh token; store hash in usermeta. + * + * @param int $user_id user id. + * + * @return string + */ + private static function issue_refresh_token( $user_id ) { + $token = bin2hex( random_bytes( 32 ) ); + $hash = hash( 'sha256', $token ); + $list = static::get_refresh_token_list( $user_id ); + $now = time(); + + $list = array_values( + array_filter( + $list, + function ( $row ) use ( $now ) { + return is_array( $row ) && ! empty( $row['hash'] ) && ! empty( $row['exp'] ) && (int) $row['exp'] > $now; + } + ) + ); + $list[] = array( + 'hash' => $hash, + 'exp' => $now + static::REFRESH_TTL, + ); + + update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); + + return $token; + } + + /** + * Validate and remove refresh token; return user id. + * + * @param string $token refresh token. + * + * @return int + */ + private static function consume_refresh_token( $token ) { + $user_id = static::find_user_id_by_refresh_token( $token ); + if ( ! $user_id ) { + return 0; + } + + static::delete_refresh_token( $token ); + return $user_id; + } + + /** + * Find user id owning a refresh token. + * + * @param string $token refresh token. + * + * @return int + */ + private static function find_user_id_by_refresh_token( $token ) { + global $wpdb; + + $hash = hash( 'sha256', $token ); + $now = time(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", + static::REFRESH_META_KEY + ) + ); + + if ( ! is_array( $rows ) ) { + return 0; + } + + foreach ( $rows as $row ) { + $list = json_decode( $row->meta_value, true ); + if ( ! is_array( $list ) ) { + continue; + } + foreach ( $list as $entry ) { + if ( empty( $entry['hash'] ) || empty( $entry['exp'] ) ) { + continue; + } + if ( hash_equals( $entry['hash'], $hash ) && (int) $entry['exp'] > $now ) { + return (int) $row->user_id; + } + } + } + + return 0; + } + + /** + * Delete one refresh token. + * + * @param string $token refresh token. + * + * @return void + */ + private static function delete_refresh_token( $token ) { + $user_id = static::find_user_id_by_refresh_token( $token ); + if ( ! $user_id ) { + // Token may already be partially matched — scan by hash after consume path. + $hash = hash( 'sha256', $token ); + static::delete_refresh_hash_for_all_users( $hash ); + return; + } + + $hash = hash( 'sha256', $token ); + $list = static::get_refresh_token_list( $user_id ); + $list = array_values( + array_filter( + $list, + function ( $row ) use ( $hash ) { + return empty( $row['hash'] ) || ! hash_equals( $row['hash'], $hash ); + } + ) + ); + update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); + } + + /** + * Remove a refresh hash across users (best-effort). + * + * @param string $hash sha256 hash. + * + * @return void + */ + private static function delete_refresh_hash_for_all_users( $hash ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT umeta_id, user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", + static::REFRESH_META_KEY + ) + ); + + if ( ! is_array( $rows ) ) { + return; + } + + foreach ( $rows as $row ) { + $list = json_decode( $row->meta_value, true ); + if ( ! is_array( $list ) ) { + continue; + } + $new = array_values( + array_filter( + $list, + function ( $entry ) use ( $hash ) { + return empty( $entry['hash'] ) || ! hash_equals( $entry['hash'], $hash ); + } + ) + ); + if ( count( $new ) !== count( $list ) ) { + update_user_meta( (int) $row->user_id, static::REFRESH_META_KEY, wp_json_encode( $new ) ); + } + } + } + + /** + * Delete all refresh tokens for a user. + * + * @param int $user_id user id. + * + * @return void + */ + private static function delete_all_refresh_tokens( $user_id ) { + delete_user_meta( $user_id, static::REFRESH_META_KEY ); + } + + /** + * Get refresh token list from usermeta. + * + * @param int $user_id user id. + * + * @return array + */ + private static function get_refresh_token_list( $user_id ) { + $raw = get_user_meta( $user_id, static::REFRESH_META_KEY, true ); + if ( empty( $raw ) ) { + return array(); + } + $list = json_decode( $raw, true ); + return is_array( $list ) ? $list : array(); + } + + /** + * Require SSL for auth endpoints (except local). + * + * @return true|\WP_Error + */ + private static function require_ssl_for_auth() { + if ( is_ssl() ) { + return true; + } + + if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) { + return true; + } + + return new \WP_Error( + 'rest_ssl_required', + __( 'HTTPS is required for authentication.', 'tutor' ), + array( 'status' => 403 ) + ); + } + + /** + * Rate-limit key for login. + * + * @param string $username username. + * + * @return string + */ + private static function login_rate_limit_key( $username ) { + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; + return 'tutor_rest_login_' . md5( strtolower( $username ) . '|' . $ip ); + } + + /** + * Whether login is rate limited. + * + * @param string $username username. + * + * @return bool + */ + private static function is_login_rate_limited( $username ) { + return (int) get_transient( static::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; + } + + /** + * Bump login failure counter. + * + * @param string $username username. + * + * @return void + */ + private static function bump_login_rate_limit( $username ) { + $key = static::login_rate_limit_key( $username ); + $count = (int) get_transient( $key ); + set_transient( $key, $count + 1, static::LOGIN_WINDOW ); + } + + /** + * Clear login rate limit on success. + * + * @param string $username username. + * + * @return void + */ + private static function clear_login_rate_limit( $username ) { + delete_transient( static::login_rate_limit_key( $username ) ); } } From fe7a1e9105bbe7f83e3e173dfd4694a7d887e34a Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 13:08:58 +0600 Subject: [PATCH 2/8] refactor(auth): enhance API key permission handling in RestAuth class - Introduced new methods for processing authenticated read, write, and delete requests, ensuring that API key permissions are validated alongside user authentication. - Refactored the existing process_api_request method to delegate permission checks to the new methods, improving code clarity and maintainability. - Updated documentation to reflect changes in permission handling and method responsibilities. --- restapi/RestAuth.php | 123 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 16 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 018811919d..cc62a21532 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -382,47 +382,105 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. + * Permission string for a valid API key/secret on this request. * - * @since 2.2.1 - * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. + * @since 4.0.10 * - * @return boolean + * @return string Empty when credentials are missing or invalid. */ - public static function process_api_request() { + private static function get_api_key_permission() { $credentials = static::get_api_credentials_from_request(); if ( ! $credentials ) { - return false; + return ''; } $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); if ( ! $record ) { - return false; + return ''; } $meta = json_decode( $record->meta_value ); if ( ! is_object( $meta ) || empty( $meta->permission ) ) { + return ''; + } + + return (string) $meta->permission; + } + + /** + * Whether the API key grants Read (or higher). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_read_request() { + $permission = static::get_api_key_permission(); + if ( '' === $permission ) { return false; } - $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; - $permission = $meta->permission; + return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + } + /** + * Whether the API key grants Write (or higher). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_write_request() { + $permission = static::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + return in_array( $permission, array( static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Whether the API key grants Delete (or Write/All). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_delete_request() { + $permission = static::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + } + + /** + * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. + * + * @since 2.2.1 + * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. + * @since 4.0.10 Delegate to process_read/write/delete_request(). + * + * @return boolean + */ + public static function process_api_request() { // Auth routes may POST with a Read key (login/refresh/logout). if ( static::is_auth_route() ) { - return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + return static::process_read_request(); } + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; + if ( 'DELETE' === $method ) { - return in_array( $permission, array( static::DELETE, static::WRITE, static::READ_WRITE, static::ALL ), true ); + return static::process_delete_request(); } - $write_methods = array( 'POST', 'PUT', 'PATCH' ); - if ( in_array( $method, $write_methods, true ) ) { - return in_array( $permission, array( static::WRITE, static::READ_WRITE, static::ALL ), true ); + if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) { + return static::process_write_request(); } - return in_array( $permission, array( static::READ, static::READ_WRITE, static::ALL ), true ); + return static::process_read_request(); } /** @@ -436,10 +494,43 @@ public static function has_authenticated_user() { return (int) get_current_user_id() > 0; } + /** + * Read-capable API key and an authenticated end user (JWT). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_read_request() { + return static::process_read_request() && static::has_authenticated_user(); + } + + /** + * Write-capable API key and an authenticated end user (JWT). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_write_request() { + return static::process_write_request() && static::has_authenticated_user(); + } + + /** + * Delete-capable API key and an authenticated end user (JWT). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_authenticated_delete_request() { + return static::process_delete_request() && static::has_authenticated_user(); + } + /** * Valid API key for this HTTP method and an authenticated end user (JWT). * - * Used by Tutor Pro REST routes. + * Used when the route does not declare a specific read/write/delete check. * * @since 4.0.10 * From 525e02cef590a4bc013adaaa25dd222705ff9365 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 15:53:17 +0600 Subject: [PATCH 3/8] refactor(auth): simplify permission handling for REST API routes - Updated permission callbacks for login, refresh, and logout routes to use a default true return value, streamlining access control. - Introduced new methods in RestAuth class to determine route types (login, refresh, logout) for better clarity and maintainability. - Enhanced documentation to reflect the changes in permission handling and route identification. --- classes/RestAPI.php | 4 +- restapi/RestAuth.php | 298 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 263 insertions(+), 39 deletions(-) diff --git a/classes/RestAPI.php b/classes/RestAPI.php index fe7bf3532a..76caa587fe 100644 --- a/classes/RestAPI.php +++ b/classes/RestAPI.php @@ -249,7 +249,7 @@ public function init_routes() { array( 'methods' => 'POST', 'callback' => array( RestAuth::class, 'rest_refresh' ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => '__return_true', ) ); @@ -259,7 +259,7 @@ public function init_routes() { array( 'methods' => 'POST', 'callback' => array( RestAuth::class, 'rest_logout' ), - 'permission_callback' => array( RestAuth::class, 'process_api_request' ), + 'permission_callback' => '__return_true', ) ); diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index cc62a21532..e826a98786 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -119,6 +119,13 @@ class RestAuth { */ const LOGIN_WINDOW = 900; + /** + * Verified access-token claims for the current request (user_id, kid). + * + * @var array{user_id:int,kid:int}|null + */ + private static $verified_token_claims = null; + /** * Register hooks. * @@ -202,6 +209,52 @@ public static function is_tutor_api_request() { * @return bool */ public static function is_auth_route() { + return static::is_login_route() || static::is_refresh_route() || static::is_logout_route(); + } + + /** + * Whether request is the auth login route (requires API key + secret). + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_login_route() { + return static::auth_path_matches( 'login' ); + } + + /** + * Whether request is the auth refresh route. + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_refresh_route() { + return static::auth_path_matches( 'refresh' ); + } + + /** + * Whether request is the auth logout route. + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_logout_route() { + return static::auth_path_matches( 'logout' ); + } + + /** + * Whether the request path matches a Tutor auth endpoint segment. + * + * @since 4.1.0 + * + * @param string $segment login|refresh|logout. + * + * @return bool + */ + private static function auth_path_matches( $segment ) { if ( empty( $_SERVER['REQUEST_URI'] ) ) { return false; } @@ -215,16 +268,9 @@ public static function is_auth_route() { $path = trailingslashit( $path ); $rest_prefix = trailingslashit( rest_get_url_prefix() ); - $base = '/' . $rest_prefix . 'tutor/v1/auth/'; - - return ( - false !== strpos( $path, $base . 'login/' ) - || false !== strpos( $path, $base . 'refresh/' ) - || false !== strpos( $path, $base . 'logout/' ) - || false !== strpos( $path, $base . 'login' ) - || false !== strpos( $path, $base . 'refresh' ) - || false !== strpos( $path, $base . 'logout' ) - ); + $base = '/' . $rest_prefix . 'tutor/v1/auth/' . $segment; + + return false !== strpos( $path, $base . '/' ) || false !== strpos( $path, $base ); } /** @@ -382,13 +428,37 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Permission string for a valid API key/secret on this request. + * Permission string for this request. + * + * Login: from API key/secret headers. + * All other Tutor REST routes: from the API key id (`kid`) bound into the access JWT. * * @since 4.0.10 + * @since 4.1.0 Non-login routes resolve permission from the access token kid. * - * @return string Empty when credentials are missing or invalid. + * @return string Empty when credentials/token are missing, invalid, or revoked. */ private static function get_api_key_permission() { + if ( static::is_login_route() ) { + return static::get_permission_from_api_credentials(); + } + + $kid = static::get_access_token_kid(); + if ( ! $kid ) { + return ''; + } + + return static::get_permission_by_kid( $kid ); + } + + /** + * Permission from API key/secret headers. + * + * @since 4.1.0 + * + * @return string + */ + private static function get_permission_from_api_credentials() { $credentials = static::get_api_credentials_from_request(); if ( ! $credentials ) { return ''; @@ -399,7 +469,44 @@ private static function get_api_key_permission() { return ''; } - $meta = json_decode( $record->meta_value ); + return static::permission_from_key_meta( $record->meta_value ); + } + + /** + * Permission for an API key usermeta row id (kid). + * + * @since 4.1.0 + * + * @param int $kid usermeta umeta_id of the API key row. + * + * @return string Empty when missing or revoked. + */ + private static function get_permission_by_kid( $kid ) { + $kid = absint( $kid ); + if ( ! $kid ) { + return ''; + } + + global $wpdb; + $record = QueryHelper::get_row( $wpdb->usermeta, array( 'umeta_id' => $kid ), 'umeta_id' ); + if ( ! $record || static::KEYS_USER_META_KEY !== $record->meta_key ) { + return ''; + } + + return static::permission_from_key_meta( $record->meta_value ); + } + + /** + * Extract permission string from API key meta JSON. + * + * @since 4.1.0 + * + * @param string $meta_value JSON meta value. + * + * @return string + */ + private static function permission_from_key_meta( $meta_value ) { + $meta = json_decode( $meta_value ); if ( ! is_object( $meta ) || empty( $meta->permission ) ) { return ''; } @@ -407,6 +514,30 @@ private static function get_api_key_permission() { return (string) $meta->permission; } + /** + * API key id (umeta_id) from the verified access token on this request. + * + * @since 4.1.0 + * + * @return int + */ + private static function get_access_token_kid() { + $token = static::get_access_token_from_request(); + if ( ! $token ) { + return 0; + } + + if ( null !== static::$verified_token_claims && isset( static::$verified_token_claims['kid'] ) ) { + return absint( static::$verified_token_claims['kid'] ); + } + + if ( ! static::verify_access_token( $token ) ) { + return 0; + } + + return isset( static::$verified_token_claims['kid'] ) ? absint( static::$verified_token_claims['kid'] ) : 0; + } + /** * Whether the API key grants Read (or higher). * @@ -456,17 +587,20 @@ public static function process_delete_request() { } /** - * Process api request — validate key/secret and honor Read/Write/All vs HTTP method. + * Process api request — honor Read/Write/All vs HTTP method. + * + * Login uses API key/secret. All other routes use the access token's bound key permission. * * @since 2.2.1 * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. * @since 4.0.10 Delegate to process_read/write/delete_request(). + * @since 4.1.0 Login-only key/secret; other routes use JWT kid permission. * * @return boolean */ public static function process_api_request() { - // Auth routes may POST with a Read key (login/refresh/logout). - if ( static::is_auth_route() ) { + // Login may POST with a Read-capable API key. + if ( static::is_login_route() ) { return static::process_read_request(); } @@ -862,7 +996,11 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id /** * Login — issue access + refresh tokens. * + * Requires a valid Read-capable API key/secret (permission_callback). The key id + * is bound into issued tokens so later requests need only the Bearer token. + * * @since 4.0.10 + * @since 4.1.0 Bind API key id (kid) into access and refresh tokens. * * @param WP_REST_Request $request request. * @@ -874,6 +1012,33 @@ public static function rest_login( WP_REST_Request $request ) { return $ssl_error; } + $credentials = static::get_api_credentials_from_request(); + if ( ! $credentials ) { + return new \WP_Error( + 'rest_forbidden', + __( 'API key and secret are required.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! $record ) { + return new \WP_Error( + 'rest_forbidden', + __( 'Invalid API key or secret.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $kid = absint( $record->umeta_id ); + if ( ! $kid || '' === static::permission_from_key_meta( $record->meta_value ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'Invalid API key or secret.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + $username = sanitize_text_field( (string) $request->get_param( 'username' ) ); $password = (string) $request->get_param( 'password' ); @@ -912,13 +1077,14 @@ public static function rest_login( WP_REST_Request $request ) { static::clear_login_rate_limit( $username ); - return rest_ensure_response( static::build_token_response( (int) $user->ID ) ); + return rest_ensure_response( static::build_token_response( (int) $user->ID, $kid ) ); } /** * Refresh access token (rotates refresh token). * * @since 4.0.10 + * @since 4.1.0 No API key/secret; reuses kid stored with the refresh token. * * @param WP_REST_Request $request request. * @@ -939,8 +1105,8 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - $user_id = static::consume_refresh_token( $refresh ); - if ( ! $user_id ) { + $session = static::consume_refresh_token( $refresh ); + if ( ! $session ) { return new \WP_Error( 'rest_invalid_refresh', __( 'Invalid refresh token.', 'tutor' ), @@ -948,7 +1114,15 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - return rest_ensure_response( static::build_token_response( $user_id ) ); + if ( '' === static::get_permission_by_kid( $session['kid'] ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'API key has been revoked.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return rest_ensure_response( static::build_token_response( $session['user_id'], $session['kid'] ) ); } /** @@ -1121,12 +1295,13 @@ public static function available_permissions(): array { * Build login/refresh response payload. * * @param int $user_id user id. + * @param int $kid API key usermeta id. * * @return array */ - private static function build_token_response( $user_id ) { - $access = static::issue_access_token( $user_id ); - $refresh = static::issue_refresh_token( $user_id ); + private static function build_token_response( $user_id, $kid ) { + $access = static::issue_access_token( $user_id, $kid ); + $refresh = static::issue_refresh_token( $user_id, $kid ); $user = get_userdata( $user_id ); return array( @@ -1142,14 +1317,23 @@ private static function build_token_response( $user_id ) { * Issue HS256 access JWT. * * @param int $user_id user id. + * @param int $kid API key usermeta id. * * @return array{token:string,expires_in:int} */ - private static function issue_access_token( $user_id ) { + private static function issue_access_token( $user_id, $kid ) { $now = time(); $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); + $kid = absint( $kid ); - $header = static::base64url_encode( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) ); + $header = static::base64url_encode( + wp_json_encode( + array( + 'alg' => 'HS256', + 'typ' => 'JWT', + ) + ) + ); $payload = static::base64url_encode( wp_json_encode( array( @@ -1158,6 +1342,7 @@ private static function issue_access_token( $user_id ) { 'exp' => $now + static::ACCESS_TTL, 'iss' => 'tutor', 'tv' => $tv, + 'kid' => $kid, ) ) ); @@ -1177,6 +1362,8 @@ private static function issue_access_token( $user_id ) { * @return int */ private static function verify_access_token( $jwt ) { + static::$verified_token_claims = null; + $parts = explode( '.', $jwt ); if ( 3 !== count( $parts ) ) { return 0; @@ -1206,6 +1393,11 @@ private static function verify_access_token( $jwt ) { return 0; } + $kid = isset( $payload->kid ) ? absint( $payload->kid ) : 0; + if ( ! $kid || '' === static::get_permission_by_kid( $kid ) ) { + return 0; + } + $user_id = (int) $payload->sub; $user = get_userdata( $user_id ); if ( ! $user || ! $user->exists() ) { @@ -1221,6 +1413,11 @@ private static function verify_access_token( $jwt ) { return 0; } + static::$verified_token_claims = array( + 'user_id' => $user_id, + 'kid' => $kid, + ); + return $user_id; } @@ -1360,17 +1557,19 @@ private static function get_request_headers() { } /** - * Issue opaque refresh token; store hash in usermeta. + * Issue opaque refresh token; store hash + kid in usermeta. * * @param int $user_id user id. + * @param int $kid API key usermeta id. * * @return string */ - private static function issue_refresh_token( $user_id ) { + private static function issue_refresh_token( $user_id, $kid ) { $token = bin2hex( random_bytes( 32 ) ); $hash = hash( 'sha256', $token ); $list = static::get_refresh_token_list( $user_id ); $now = time(); + $kid = absint( $kid ); $list = array_values( array_filter( @@ -1383,6 +1582,7 @@ function ( $row ) use ( $now ) { $list[] = array( 'hash' => $hash, 'exp' => $now + static::REFRESH_TTL, + 'kid' => $kid, ); update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); @@ -1391,20 +1591,20 @@ function ( $row ) use ( $now ) { } /** - * Validate and remove refresh token; return user id. + * Validate and remove refresh token; return user id + kid. * * @param string $token refresh token. * - * @return int + * @return array{user_id:int,kid:int}|null */ private static function consume_refresh_token( $token ) { - $user_id = static::find_user_id_by_refresh_token( $token ); - if ( ! $user_id ) { - return 0; + $session = static::find_refresh_session( $token ); + if ( ! $session ) { + return null; } static::delete_refresh_token( $token ); - return $user_id; + return $session; } /** @@ -1415,6 +1615,20 @@ private static function consume_refresh_token( $token ) { * @return int */ private static function find_user_id_by_refresh_token( $token ) { + $session = static::find_refresh_session( $token ); + return $session ? $session['user_id'] : 0; + } + + /** + * Find refresh session (user id + kid) for a refresh token. + * + * @since 4.1.0 + * + * @param string $token refresh token. + * + * @return array{user_id:int,kid:int}|null + */ + private static function find_refresh_session( $token ) { global $wpdb; $hash = hash( 'sha256', $token ); @@ -1429,7 +1643,7 @@ private static function find_user_id_by_refresh_token( $token ) { ); if ( ! is_array( $rows ) ) { - return 0; + return null; } foreach ( $rows as $row ) { @@ -1441,13 +1655,23 @@ private static function find_user_id_by_refresh_token( $token ) { if ( empty( $entry['hash'] ) || empty( $entry['exp'] ) ) { continue; } - if ( hash_equals( $entry['hash'], $hash ) && (int) $entry['exp'] > $now ) { - return (int) $row->user_id; + if ( ! hash_equals( $entry['hash'], $hash ) || (int) $entry['exp'] <= $now ) { + continue; + } + + $kid = isset( $entry['kid'] ) ? absint( $entry['kid'] ) : 0; + if ( ! $kid ) { + return null; } + + return array( + 'user_id' => (int) $row->user_id, + 'kid' => $kid, + ); } } - return 0; + return null; } /** From 8eb23c73d0752993b12ad9a04ee84da94f14caba Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 24 Sep 2026 15:59:27 +0600 Subject: [PATCH 4/8] refactor(auth): update permission checks for API key management - Changed permission checks in the RestAuth class from 'administrator' to 'manage_options' for generating and revoking API keys. - This adjustment aligns permission handling with WordPress best practices, ensuring that only users with the appropriate capabilities can manage API keys. --- restapi/RestAuth.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index e826a98786..0e01f3b653 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -283,7 +283,7 @@ private static function auth_path_matches( $segment ) { public static function generate_api_keys() { tutor_utils()->checking_nonce(); - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -328,7 +328,7 @@ public static function update_api_permission() { tutor_utils()->checking_nonce(); - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -367,7 +367,7 @@ public static function update_api_permission() { public static function revoke_api_keys() { tutor_utils()->checking_nonce(); - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } From e0e17d32ac28ea309b39eff7eab67b68eb9f704a Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 15:44:31 +0600 Subject: [PATCH 5/8] refactor(auth): improve base64 encoding/decoding methods in RestAuth class - Replaced the deprecated base64_encode and base64_decode functions with sodium_bin2base64 and sodium_base642bin for enhanced security and JWT compatibility. - Updated method documentation to clarify that the encoding and decoding are now JWT-safe and do not include padding. - Added error handling for decoding to ensure robustness against invalid input. --- restapi/RestAuth.php | 145 ++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 71 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 0e01f3b653..afaa51b41b 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -159,12 +159,12 @@ public function api_auth( $user_id ) { return $user_id; } - $token = static::get_access_token_from_request(); + $token = self::get_access_token_from_request(); if ( ! $token ) { return $user_id; } - $jwt_user_id = static::verify_access_token( $token ); + $jwt_user_id = self::verify_access_token( $token ); if ( ! $jwt_user_id ) { return $user_id; } @@ -220,7 +220,7 @@ public static function is_auth_route() { * @return bool */ public static function is_login_route() { - return static::auth_path_matches( 'login' ); + return self::auth_path_matches( 'login' ); } /** @@ -231,7 +231,7 @@ public static function is_login_route() { * @return bool */ public static function is_refresh_route() { - return static::auth_path_matches( 'refresh' ); + return self::auth_path_matches( 'refresh' ); } /** @@ -242,7 +242,7 @@ public static function is_refresh_route() { * @return bool */ public static function is_logout_route() { - return static::auth_path_matches( 'logout' ); + return self::auth_path_matches( 'logout' ); } /** @@ -440,15 +440,15 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r */ private static function get_api_key_permission() { if ( static::is_login_route() ) { - return static::get_permission_from_api_credentials(); + return self::get_permission_from_api_credentials(); } - $kid = static::get_access_token_kid(); + $kid = self::get_access_token_kid(); if ( ! $kid ) { return ''; } - return static::get_permission_by_kid( $kid ); + return self::get_permission_by_kid( $kid ); } /** @@ -459,17 +459,17 @@ private static function get_api_key_permission() { * @return string */ private static function get_permission_from_api_credentials() { - $credentials = static::get_api_credentials_from_request(); + $credentials = self::get_api_credentials_from_request(); if ( ! $credentials ) { return ''; } $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); - if ( ! $record ) { + if ( ! is_object( $record ) ) { return ''; } - return static::permission_from_key_meta( $record->meta_value ); + return self::permission_from_key_meta( $record->meta_value ); } /** @@ -493,7 +493,7 @@ private static function get_permission_by_kid( $kid ) { return ''; } - return static::permission_from_key_meta( $record->meta_value ); + return self::permission_from_key_meta( $record->meta_value ); } /** @@ -522,20 +522,20 @@ private static function permission_from_key_meta( $meta_value ) { * @return int */ private static function get_access_token_kid() { - $token = static::get_access_token_from_request(); + $token = self::get_access_token_from_request(); if ( ! $token ) { return 0; } - if ( null !== static::$verified_token_claims && isset( static::$verified_token_claims['kid'] ) ) { - return absint( static::$verified_token_claims['kid'] ); + if ( null !== self::$verified_token_claims && isset( self::$verified_token_claims['kid'] ) ) { + return absint( self::$verified_token_claims['kid'] ); } - if ( ! static::verify_access_token( $token ) ) { + if ( ! self::verify_access_token( $token ) ) { return 0; } - return isset( static::$verified_token_claims['kid'] ) ? absint( static::$verified_token_claims['kid'] ) : 0; + return isset( self::$verified_token_claims['kid'] ) ? absint( self::$verified_token_claims['kid'] ) : 0; } /** @@ -546,7 +546,7 @@ private static function get_access_token_kid() { * @return bool */ public static function process_read_request() { - $permission = static::get_api_key_permission(); + $permission = self::get_api_key_permission(); if ( '' === $permission ) { return false; } @@ -562,7 +562,7 @@ public static function process_read_request() { * @return bool */ public static function process_write_request() { - $permission = static::get_api_key_permission(); + $permission = self::get_api_key_permission(); if ( '' === $permission ) { return false; } @@ -578,7 +578,7 @@ public static function process_write_request() { * @return bool */ public static function process_delete_request() { - $permission = static::get_api_key_permission(); + $permission = self::get_api_key_permission(); if ( '' === $permission ) { return false; } @@ -1007,12 +1007,12 @@ public static function can_view_user_private_fields( $target_user_id, $viewer_id * @return \WP_REST_Response|\WP_Error */ public static function rest_login( WP_REST_Request $request ) { - $ssl_error = static::require_ssl_for_auth(); + $ssl_error = self::require_ssl_for_auth(); if ( is_wp_error( $ssl_error ) ) { return $ssl_error; } - $credentials = static::get_api_credentials_from_request(); + $credentials = self::get_api_credentials_from_request(); if ( ! $credentials ) { return new \WP_Error( 'rest_forbidden', @@ -1022,7 +1022,7 @@ public static function rest_login( WP_REST_Request $request ) { } $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); - if ( ! $record ) { + if ( ! is_object( $record ) ) { return new \WP_Error( 'rest_forbidden', __( 'Invalid API key or secret.', 'tutor' ), @@ -1031,7 +1031,7 @@ public static function rest_login( WP_REST_Request $request ) { } $kid = absint( $record->umeta_id ); - if ( ! $kid || '' === static::permission_from_key_meta( $record->meta_value ) ) { + if ( ! $kid || '' === self::permission_from_key_meta( $record->meta_value ) ) { return new \WP_Error( 'rest_forbidden', __( 'Invalid API key or secret.', 'tutor' ), @@ -1057,7 +1057,7 @@ public static function rest_login( WP_REST_Request $request ) { } } - if ( static::is_login_rate_limited( $username ) ) { + if ( self::is_login_rate_limited( $username ) ) { return new \WP_Error( 'rest_login_limited', __( 'Too many failed login attempts. Please try again later.', 'tutor' ), @@ -1067,7 +1067,7 @@ public static function rest_login( WP_REST_Request $request ) { $user = wp_authenticate( $username, $password ); if ( is_wp_error( $user ) ) { - static::bump_login_rate_limit( $username ); + self::bump_login_rate_limit( $username ); return new \WP_Error( 'rest_invalid_credentials', __( 'Invalid username or password.', 'tutor' ), @@ -1075,9 +1075,9 @@ public static function rest_login( WP_REST_Request $request ) { ); } - static::clear_login_rate_limit( $username ); + self::clear_login_rate_limit( $username ); - return rest_ensure_response( static::build_token_response( (int) $user->ID, $kid ) ); + return rest_ensure_response( self::build_token_response( (int) $user->ID, $kid ) ); } /** @@ -1091,7 +1091,7 @@ public static function rest_login( WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public static function rest_refresh( WP_REST_Request $request ) { - $ssl_error = static::require_ssl_for_auth(); + $ssl_error = self::require_ssl_for_auth(); if ( is_wp_error( $ssl_error ) ) { return $ssl_error; } @@ -1105,7 +1105,7 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - $session = static::consume_refresh_token( $refresh ); + $session = self::consume_refresh_token( $refresh ); if ( ! $session ) { return new \WP_Error( 'rest_invalid_refresh', @@ -1114,7 +1114,7 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - if ( '' === static::get_permission_by_kid( $session['kid'] ) ) { + if ( '' === self::get_permission_by_kid( $session['kid'] ) ) { return new \WP_Error( 'rest_forbidden', __( 'API key has been revoked.', 'tutor' ), @@ -1122,7 +1122,7 @@ public static function rest_refresh( WP_REST_Request $request ) { ); } - return rest_ensure_response( static::build_token_response( $session['user_id'], $session['kid'] ) ); + return rest_ensure_response( self::build_token_response( $session['user_id'], $session['kid'] ) ); } /** @@ -1135,7 +1135,7 @@ public static function rest_refresh( WP_REST_Request $request ) { * @return \WP_REST_Response|\WP_Error */ public static function rest_logout( WP_REST_Request $request ) { - $ssl_error = static::require_ssl_for_auth(); + $ssl_error = self::require_ssl_for_auth(); if ( is_wp_error( $ssl_error ) ) { return $ssl_error; } @@ -1144,16 +1144,16 @@ public static function rest_logout( WP_REST_Request $request ) { $all = (bool) $request->get_param( 'all' ); if ( $all ) { - $token = static::get_access_token_from_request(); - $user_id = $token ? static::verify_access_token( $token ) : 0; + $token = self::get_access_token_from_request(); + $user_id = $token ? self::verify_access_token( $token ) : 0; if ( ! $user_id && $refresh ) { - $user_id = static::find_user_id_by_refresh_token( $refresh ); + $user_id = self::find_user_id_by_refresh_token( $refresh ); } if ( $user_id ) { - static::delete_all_refresh_tokens( $user_id ); + self::delete_all_refresh_tokens( $user_id ); } } elseif ( $refresh ) { - static::delete_refresh_token( $refresh ); + self::delete_refresh_token( $refresh ); } return rest_ensure_response( @@ -1201,7 +1201,7 @@ public static function invalidate_user_tokens( $user ) { $version = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); update_user_meta( $user_id, static::TOKEN_VERSION_META, $version + 1 ); - static::delete_all_refresh_tokens( $user_id ); + self::delete_all_refresh_tokens( $user_id ); } /** @@ -1300,8 +1300,8 @@ public static function available_permissions(): array { * @return array */ private static function build_token_response( $user_id, $kid ) { - $access = static::issue_access_token( $user_id, $kid ); - $refresh = static::issue_refresh_token( $user_id, $kid ); + $access = self::issue_access_token( $user_id, $kid ); + $refresh = self::issue_refresh_token( $user_id, $kid ); $user = get_userdata( $user_id ); return array( @@ -1326,7 +1326,7 @@ private static function issue_access_token( $user_id, $kid ) { $tv = (int) get_user_meta( $user_id, static::TOKEN_VERSION_META, true ); $kid = absint( $kid ); - $header = static::base64url_encode( + $header = self::base64url_encode( wp_json_encode( array( 'alg' => 'HS256', @@ -1334,7 +1334,7 @@ private static function issue_access_token( $user_id, $kid ) { ) ) ); - $payload = static::base64url_encode( + $payload = self::base64url_encode( wp_json_encode( array( 'sub' => (int) $user_id, @@ -1346,7 +1346,7 @@ private static function issue_access_token( $user_id, $kid ) { ) ) ); - $sig = static::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, static::jwt_secret(), true ) ); + $sig = self::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, self::jwt_secret(), true ) ); return array( 'token' => $header . '.' . $payload . '.' . $sig, @@ -1362,7 +1362,7 @@ private static function issue_access_token( $user_id, $kid ) { * @return int */ private static function verify_access_token( $jwt ) { - static::$verified_token_claims = null; + self::$verified_token_claims = null; $parts = explode( '.', $jwt ); if ( 3 !== count( $parts ) ) { @@ -1371,15 +1371,15 @@ private static function verify_access_token( $jwt ) { list( $header_b64, $payload_b64, $sig_b64 ) = $parts; - $expected = static::base64url_encode( - hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, static::jwt_secret(), true ) + $expected = self::base64url_encode( + hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, self::jwt_secret(), true ) ); if ( ! hash_equals( $expected, $sig_b64 ) ) { return 0; } - $payload_json = static::base64url_decode( $payload_b64 ); + $payload_json = self::base64url_decode( $payload_b64 ); $payload = json_decode( $payload_json ); if ( ! is_object( $payload ) || empty( $payload->sub ) || empty( $payload->exp ) ) { return 0; @@ -1394,7 +1394,7 @@ private static function verify_access_token( $jwt ) { } $kid = isset( $payload->kid ) ? absint( $payload->kid ) : 0; - if ( ! $kid || '' === static::get_permission_by_kid( $kid ) ) { + if ( ! $kid || '' === self::get_permission_by_kid( $kid ) ) { return 0; } @@ -1413,7 +1413,7 @@ private static function verify_access_token( $jwt ) { return 0; } - static::$verified_token_claims = array( + self::$verified_token_claims = array( 'user_id' => $user_id, 'kid' => $kid, ); @@ -1443,30 +1443,29 @@ private static function jwt_secret() { } /** - * Base64 URL encode. + * Base64 URL encode (JWT-safe, no padding). * * @param string $data raw. * * @return string */ private static function base64url_encode( $data ) { - return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return sodium_bin2base64( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); } /** - * Base64 URL decode. + * Base64 URL decode (JWT-safe, no padding). * * @param string $data encoded. * * @return string */ private static function base64url_decode( $data ) { - $remainder = strlen( $data ) % 4; - if ( $remainder ) { - $data .= str_repeat( '=', 4 - $remainder ); + try { + return sodium_base642bin( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); + } catch ( \Throwable $e ) { + return ''; } - $decoded = base64_decode( strtr( $data, '-_', '+/' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - return false === $decoded ? '' : $decoded; } /** @@ -1475,7 +1474,7 @@ private static function base64url_decode( $data ) { * @return string */ private static function get_access_token_from_request() { - $headers = static::get_request_headers(); + $headers = self::get_request_headers(); if ( ! empty( $headers['x-tutor-user-token'] ) ) { return trim( $headers['x-tutor-user-token'] ); @@ -1494,7 +1493,7 @@ private static function get_access_token_from_request() { * @return array{key:string,secret:string}|null */ private static function get_api_credentials_from_request() { - $headers = static::get_request_headers(); + $headers = self::get_request_headers(); if ( ! empty( $headers['x-tutor-api-key'] ) && ! empty( $headers['x-tutor-api-secret'] ) ) { return array( @@ -1505,7 +1504,11 @@ private static function get_api_credentials_from_request() { $auth = $headers['authorization'] ?? ''; if ( $auth && 0 === stripos( $auth, 'Basic ' ) ) { - $decoded = base64_decode( substr( $auth, 6 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + try { + $decoded = sodium_base642bin( substr( $auth, 6 ), SODIUM_BASE64_VARIANT_ORIGINAL ); + } catch ( \Throwable $e ) { + $decoded = ''; + } if ( is_string( $decoded ) && false !== strpos( $decoded, ':' ) ) { list( $key, $secret ) = explode( ':', $decoded, 2 ); return array( @@ -1567,7 +1570,7 @@ private static function get_request_headers() { private static function issue_refresh_token( $user_id, $kid ) { $token = bin2hex( random_bytes( 32 ) ); $hash = hash( 'sha256', $token ); - $list = static::get_refresh_token_list( $user_id ); + $list = self::get_refresh_token_list( $user_id ); $now = time(); $kid = absint( $kid ); @@ -1598,12 +1601,12 @@ function ( $row ) use ( $now ) { * @return array{user_id:int,kid:int}|null */ private static function consume_refresh_token( $token ) { - $session = static::find_refresh_session( $token ); + $session = self::find_refresh_session( $token ); if ( ! $session ) { return null; } - static::delete_refresh_token( $token ); + self::delete_refresh_token( $token ); return $session; } @@ -1615,7 +1618,7 @@ private static function consume_refresh_token( $token ) { * @return int */ private static function find_user_id_by_refresh_token( $token ) { - $session = static::find_refresh_session( $token ); + $session = self::find_refresh_session( $token ); return $session ? $session['user_id'] : 0; } @@ -1682,16 +1685,16 @@ private static function find_refresh_session( $token ) { * @return void */ private static function delete_refresh_token( $token ) { - $user_id = static::find_user_id_by_refresh_token( $token ); + $user_id = self::find_user_id_by_refresh_token( $token ); if ( ! $user_id ) { // Token may already be partially matched — scan by hash after consume path. $hash = hash( 'sha256', $token ); - static::delete_refresh_hash_for_all_users( $hash ); + self::delete_refresh_hash_for_all_users( $hash ); return; } $hash = hash( 'sha256', $token ); - $list = static::get_refresh_token_list( $user_id ); + $list = self::get_refresh_token_list( $user_id ); $list = array_values( array_filter( $list, @@ -1812,7 +1815,7 @@ private static function login_rate_limit_key( $username ) { * @return bool */ private static function is_login_rate_limited( $username ) { - return (int) get_transient( static::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; + return (int) get_transient( self::login_rate_limit_key( $username ) ) >= static::LOGIN_MAX_ATTEMPTS; } /** @@ -1823,7 +1826,7 @@ private static function is_login_rate_limited( $username ) { * @return void */ private static function bump_login_rate_limit( $username ) { - $key = static::login_rate_limit_key( $username ); + $key = self::login_rate_limit_key( $username ); $count = (int) get_transient( $key ); set_transient( $key, $count + 1, static::LOGIN_WINDOW ); } @@ -1836,6 +1839,6 @@ private static function bump_login_rate_limit( $username ) { * @return void */ private static function clear_login_rate_limit( $username ) { - delete_transient( static::login_rate_limit_key( $username ) ); + delete_transient( self::login_rate_limit_key( $username ) ); } } From 6945808e741e949cd84dfa8b6f35ab7682a5191c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 15:54:34 +0600 Subject: [PATCH 6/8] refactor(auth): update header naming conventions in RestAuth class - Changed header names from 'X-Tutor-Api-Key' and 'X-Tutor-User-Token' to 'Tutor-Api-Key' and 'Tutor-User-Token' for consistency and clarity. - Updated method documentation to reflect the new header names, ensuring accurate API usage guidance. --- restapi/RestAuth.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index afaa51b41b..a36b667a04 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -592,7 +592,7 @@ public static function process_delete_request() { * Login uses API key/secret. All other routes use the access token's bound key permission. * * @since 2.2.1 - * @since 4.0.10 Honor key permission; accept X-Tutor-Api-Key headers. + * @since 4.0.10 Honor key permission; accept Tutor-Api-Key headers. * @since 4.0.10 Delegate to process_read/write/delete_request(). * @since 4.1.0 Login-only key/secret; other routes use JWT kid permission. * @@ -1469,15 +1469,15 @@ private static function base64url_decode( $data ) { } /** - * Read access token from Authorization Bearer or X-Tutor-User-Token. + * Read access token from Authorization Bearer or Tutor-User-Token. * * @return string */ private static function get_access_token_from_request() { $headers = self::get_request_headers(); - if ( ! empty( $headers['x-tutor-user-token'] ) ) { - return trim( $headers['x-tutor-user-token'] ); + if ( ! empty( $headers['tutor-user-token'] ) ) { + return trim( $headers['tutor-user-token'] ); } if ( ! empty( $headers['authorization'] ) && 0 === stripos( $headers['authorization'], 'Bearer ' ) ) { @@ -1488,17 +1488,17 @@ private static function get_access_token_from_request() { } /** - * Read API key/secret from Basic auth or X-Tutor-Api-* headers. + * Read API key/secret from Basic auth or Tutor-Api-* headers. * * @return array{key:string,secret:string}|null */ private static function get_api_credentials_from_request() { $headers = self::get_request_headers(); - if ( ! empty( $headers['x-tutor-api-key'] ) && ! empty( $headers['x-tutor-api-secret'] ) ) { + if ( ! empty( $headers['tutor-api-key'] ) && ! empty( $headers['tutor-api-secret'] ) ) { return array( - 'key' => sanitize_text_field( $headers['x-tutor-api-key'] ), - 'secret' => sanitize_text_field( $headers['x-tutor-api-secret'] ), + 'key' => sanitize_text_field( $headers['tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), ); } From b8a6e455452bb03e6e51684301ba72441cf27592 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 16:01:25 +0600 Subject: [PATCH 7/8] refactor(auth): streamline API key retrieval in RestAuth class - Simplified the get_api_credentials_from_request method to exclusively read API key and secret from Tutor-Api-* headers, removing support for Basic auth and PHP_AUTH_USER/PW. - Updated method documentation to reflect the changes in credential retrieval, ensuring clarity for API users. --- restapi/RestAuth.php | 37 +++++++------------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index a36b667a04..2775943320 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1488,44 +1488,21 @@ private static function get_access_token_from_request() { } /** - * Read API key/secret from Basic auth or Tutor-Api-* headers. + * Read API key/secret from Tutor-Api-* headers. * * @return array{key:string,secret:string}|null */ private static function get_api_credentials_from_request() { $headers = self::get_request_headers(); - if ( ! empty( $headers['tutor-api-key'] ) && ! empty( $headers['tutor-api-secret'] ) ) { - return array( - 'key' => sanitize_text_field( $headers['tutor-api-key'] ), - 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), - ); - } - - $auth = $headers['authorization'] ?? ''; - if ( $auth && 0 === stripos( $auth, 'Basic ' ) ) { - try { - $decoded = sodium_base642bin( substr( $auth, 6 ), SODIUM_BASE64_VARIANT_ORIGINAL ); - } catch ( \Throwable $e ) { - $decoded = ''; - } - if ( is_string( $decoded ) && false !== strpos( $decoded, ':' ) ) { - list( $key, $secret ) = explode( ':', $decoded, 2 ); - return array( - 'key' => sanitize_text_field( $key ), - 'secret' => sanitize_text_field( $secret ), - ); - } - } - - if ( isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) { - return array( - 'key' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) ), - 'secret' => sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ), - ); + if ( empty( $headers['tutor-api-key'] ) || empty( $headers['tutor-api-secret'] ) ) { + return null; } - return null; + return array( + 'key' => sanitize_text_field( $headers['tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), + ); } /** From 33b84d034944c07106c700e04e1a9cf8b2e44b30 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 25 Sep 2026 16:08:15 +0600 Subject: [PATCH 8/8] fix(auth): sanitize authorization header in RestAuth class - Updated the handling of the REDIRECT_HTTP_AUTHORIZATION server variable to sanitize its value using sanitize_text_field before assigning it to the headers array. - This change enhances security by ensuring that the authorization header is properly sanitized, preventing potential security vulnerabilities. --- restapi/RestAuth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/restapi/RestAuth.php b/restapi/RestAuth.php index 2775943320..17175d3a2e 100644 --- a/restapi/RestAuth.php +++ b/restapi/RestAuth.php @@ -1530,7 +1530,7 @@ private static function get_request_headers() { } if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) && empty( $headers['authorization'] ) ) { - $headers['authorization'] = wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $headers['authorization'] = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); } return $headers;