diff --git a/classes/RestAPI.php b/classes/RestAPI.php index 738bd02561..76caa587fe 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' => '__return_true', + ) + ); + + register_rest_route( + $this->namespace, + '/auth/logout', + array( + 'methods' => 'POST', + 'callback' => array( RestAuth::class, 'rest_logout' ), + 'permission_callback' => '__return_true', + ) + ); + // 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..17175d3a2e 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,62 @@ 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; + + /** + * 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. * @@ -79,57 +138,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 = self::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 = self::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 +190,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 +201,78 @@ 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() { + 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 self::auth_path_matches( 'login' ); + } + + /** + * Whether request is the auth refresh route. + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_refresh_route() { + return self::auth_path_matches( 'refresh' ); + } + + /** + * Whether request is the auth logout route. + * + * @since 4.1.0 + * + * @return bool + */ + public static function is_logout_route() { + return self::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; + } + + $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/' . $segment; + + return false !== strpos( $path, $base . '/' ) || false !== strpos( $path, $base ); + } + /** * Generate api keys * @@ -164,11 +281,9 @@ 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' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -187,23 +302,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,11 +326,9 @@ 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' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -232,15 +342,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,11 +365,9 @@ 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' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( tutor_utils()->error_message() ); } @@ -270,7 +377,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 +407,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 +428,1394 @@ public static function validate_api_key_secret( $api_key, $api_secret, $return_r } /** - * Process api request + * Permission string for this request. * - * @since 2.2.1 + * Login: from API key/secret headers. + * All other Tutor REST routes: from the API key id (`kid`) bound into the access JWT. * - * @return boolean + * @since 4.0.10 + * @since 4.1.0 Non-login routes resolve permission from the access token kid. + * + * @return string Empty when credentials/token are missing, invalid, or revoked. */ - public static function process_api_request() { - $headers = apache_request_headers(); + private static function get_api_key_permission() { + if ( static::is_login_route() ) { + return self::get_permission_from_api_credentials(); + } + + $kid = self::get_access_token_kid(); + if ( ! $kid ) { + return ''; + } + + return self::get_permission_by_kid( $kid ); + } - if ( isset( $headers['Authorization'] ) ) { - $authorization_header = $headers['Authorization']; + /** + * Permission from API key/secret headers. + * + * @since 4.1.0 + * + * @return string + */ + private static function get_permission_from_api_credentials() { + $credentials = self::get_api_credentials_from_request(); + if ( ! $credentials ) { + return ''; + } - if ( strpos( $authorization_header, 'Basic' ) !== false ) { - $base_64_credentials = str_replace( 'Basic ', '', $authorization_header ); - $credentials = base64_decode( $base_64_credentials ); //phpcs:ignore + $record = static::validate_api_key_secret( $credentials['key'], $credentials['secret'], true ); + if ( ! is_object( $record ) ) { + return ''; + } - list($api_key, $api_secret) = explode( ':', $credentials ); + return self::permission_from_key_meta( $record->meta_value ); + } - if ( self::validate_api_key_secret( $api_key, $api_secret ) ) { - return true; - } - } + /** + * 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 ''; } - // Key and secret are invalid or not provided. - return false; + 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 self::permission_from_key_meta( $record->meta_value ); } /** - * Prepare html response + * Extract permission string from API key meta JSON. * - * @since 2.2.1 + * @since 4.1.0 * - * @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 string $meta_value JSON meta value. * * @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 ) ); ?> - - - - - - - - - - - - - - - - - - - - -
- - - - -
- - - -
- -
- - - - - - - - -
-
- - - permission ) ) { + return ''; + } + + return (string) $meta->permission; } /** - * Get available 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 = self::get_access_token_from_request(); + if ( ! $token ) { + return 0; + } + + if ( null !== self::$verified_token_claims && isset( self::$verified_token_claims['kid'] ) ) { + return absint( self::$verified_token_claims['kid'] ); + } + + if ( ! self::verify_access_token( $token ) ) { + return 0; + } + + return isset( self::$verified_token_claims['kid'] ) ? absint( self::$verified_token_claims['kid'] ) : 0; + } + + /** + * Whether the API key grants Read (or higher). + * + * @since 4.0.10 + * + * @return bool + */ + public static function process_read_request() { + $permission = self::get_api_key_permission(); + if ( '' === $permission ) { + return false; + } + + 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 = self::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 = self::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 — 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 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 array + * @return boolean */ - 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 process_api_request() { + // Login may POST with a Read-capable API key. + if ( static::is_login_route() ) { + return static::process_read_request(); + } + + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; + + if ( 'DELETE' === $method ) { + return static::process_delete_request(); + } + + if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) { + return static::process_write_request(); + } + + return static::process_read_request(); + } + + /** + * 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; + } + + /** + * 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 when the route does not declare a specific read/write/delete check. + * + * @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() ) + ); + } + } + + $course_id = absint( $request->get_param( 'course_id' ) ); + + 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() ) + ); + } + } + + // 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 ( ! $allowed ) { + return new \WP_Error( + 'rest_forbidden_user', + __( 'You are not allowed to act as this user.', 'tutor' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } + + /** + * 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; + } + + /** + * Permission: valid API key and may view course learning content. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + public static function permission_course_content( WP_REST_Request $request ) { + if ( ! static::process_api_request() ) { + return false; + } + + $course_id = absint( $request->get_param( 'id' ) ); + if ( ! $course_id ) { + $course_id = absint( $request->get_param( 'course_id' ) ); + } + + return static::can_view_course_content( $course_id ); + } + + /** + * Permission: topics by course_id. + * + * @since 4.0.10 + * + * @param WP_REST_Request $request request. + * + * @return bool + */ + 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. + * + * 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. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_login( WP_REST_Request $request ) { + $ssl_error = self::require_ssl_for_auth(); + if ( is_wp_error( $ssl_error ) ) { + return $ssl_error; + } + + $credentials = self::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 ( ! is_object( $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 || '' === self::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' ); + + 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 ( self::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 ) ) { + self::bump_login_rate_limit( $username ); + return new \WP_Error( + 'rest_invalid_credentials', + __( 'Invalid username or password.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + self::clear_login_rate_limit( $username ); + + return rest_ensure_response( self::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. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function rest_refresh( WP_REST_Request $request ) { + $ssl_error = self::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 ) + ); + } + + $session = self::consume_refresh_token( $refresh ); + if ( ! $session ) { + return new \WP_Error( + 'rest_invalid_refresh', + __( 'Invalid refresh token.', 'tutor' ), + array( 'status' => 401 ) + ); + } + + if ( '' === self::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( self::build_token_response( $session['user_id'], $session['kid'] ) ); + } + + /** + * 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 = self::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 = self::get_access_token_from_request(); + $user_id = $token ? self::verify_access_token( $token ) : 0; + if ( ! $user_id && $refresh ) { + $user_id = self::find_user_id_by_refresh_token( $refresh ); + } + if ( $user_id ) { + self::delete_all_refresh_tokens( $user_id ); + } + } elseif ( $refresh ) { + self::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 ); + self::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. + * @param int $kid API key usermeta id. + * + * @return array + */ + private static function build_token_response( $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( + '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. + * @param int $kid API key usermeta id. + * + * @return array{token:string,expires_in:int} + */ + 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 = self::base64url_encode( + wp_json_encode( + array( + 'alg' => 'HS256', + 'typ' => 'JWT', + ) + ) + ); + $payload = self::base64url_encode( + wp_json_encode( + array( + 'sub' => (int) $user_id, + 'iat' => $now, + 'exp' => $now + static::ACCESS_TTL, + 'iss' => 'tutor', + 'tv' => $tv, + 'kid' => $kid, + ) + ) + ); + $sig = self::base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, self::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 ) { + self::$verified_token_claims = null; + + $parts = explode( '.', $jwt ); + if ( 3 !== count( $parts ) ) { + return 0; + } + + list( $header_b64, $payload_b64, $sig_b64 ) = $parts; + + $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 = self::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; + } + + $kid = isset( $payload->kid ) ? absint( $payload->kid ) : 0; + if ( ! $kid || '' === self::get_permission_by_kid( $kid ) ) { + 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; + } + + self::$verified_token_claims = array( + 'user_id' => $user_id, + 'kid' => $kid, + ); + + 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 (JWT-safe, no padding). + * + * @param string $data raw. + * + * @return string + */ + private static function base64url_encode( $data ) { + return sodium_bin2base64( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); + } + + /** + * Base64 URL decode (JWT-safe, no padding). + * + * @param string $data encoded. + * + * @return string + */ + private static function base64url_decode( $data ) { + try { + return sodium_base642bin( $data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING ); + } catch ( \Throwable $e ) { + return ''; + } + } + + /** + * 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['tutor-user-token'] ) ) { + return trim( $headers['tutor-user-token'] ); + } + + if ( ! empty( $headers['authorization'] ) && 0 === stripos( $headers['authorization'], 'Bearer ' ) ) { + return trim( substr( $headers['authorization'], 7 ) ); + } + + return ''; + } + + /** + * 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 null; + } + + return array( + 'key' => sanitize_text_field( $headers['tutor-api-key'] ), + 'secret' => sanitize_text_field( $headers['tutor-api-secret'] ), + ); + } + + /** + * 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'] = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + } + + return $headers; + } + + /** + * 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, $kid ) { + $token = bin2hex( random_bytes( 32 ) ); + $hash = hash( 'sha256', $token ); + $list = self::get_refresh_token_list( $user_id ); + $now = time(); + $kid = absint( $kid ); + + $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, + 'kid' => $kid, + ); + + update_user_meta( $user_id, static::REFRESH_META_KEY, wp_json_encode( $list ) ); + + return $token; + } + + /** + * Validate and remove refresh token; return user id + kid. + * + * @param string $token refresh token. + * + * @return array{user_id:int,kid:int}|null + */ + private static function consume_refresh_token( $token ) { + $session = self::find_refresh_session( $token ); + if ( ! $session ) { + return null; + } + + self::delete_refresh_token( $token ); + return $session; + } + + /** + * Find user id owning a refresh token. + * + * @param string $token refresh token. + * + * @return int + */ + private static function find_user_id_by_refresh_token( $token ) { + $session = self::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 ); + $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 null; + } + + 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 ) { + continue; + } + + $kid = isset( $entry['kid'] ) ? absint( $entry['kid'] ) : 0; + if ( ! $kid ) { + return null; + } + + return array( + 'user_id' => (int) $row->user_id, + 'kid' => $kid, + ); + } + } + + return null; + } + + /** + * Delete one refresh token. + * + * @param string $token refresh token. + * + * @return void + */ + private static function delete_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 ); + self::delete_refresh_hash_for_all_users( $hash ); + return; + } + + $hash = hash( 'sha256', $token ); + $list = self::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( self::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 = self::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( self::login_rate_limit_key( $username ) ); } }