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 ) );
}
}