PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.8
4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 4.2.1 All 138 releases
learnpress / inc / MCP / Auth / ApiKeyAuthenticator.php

ApiKeyAuthenticator.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.3.8, at inc/MCP/Auth/ApiKeyAuthenticator.php

426 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace LearnPress\MCP\Auth;
4
5 use LP_Helper;
6 use WP_Error;
7 use WP_REST_Request;
8
9 defined( 'ABSPATH' ) || exit;
10
11 /**
12 * Authenticates MCP HTTP transport requests using LearnPress API keys.
13 */
14 class ApiKeyAuthenticator {
15 /**
16 * Core MCP REST route path.
17 */
18 public const MCP_ROUTE = '/mcp/mcp-adapter-default-server';
19
20 /**
21 * LearnPress MCP alias route.
22 */
23 public const MCP_ALIAS_ROUTE = '/lp/v1/mcp';
24 /**
25 * @var self|null
26 */
27 protected static $instance;
28
29 /**
30 * @var ApiKeysRepository
31 */
32 protected $keys_repository;
33
34 /**
35 * @var WP_Error|null
36 */
37 protected $auth_error;
38
39 /**
40 * @var bool
41 */
42 protected $api_key_present = false;
43
44 /**
45 * @var bool
46 */
47 protected $is_target_rest_request = false;
48 /**
49 * Bootstrap singleton.
50 *
51 * @return void
52 */
53 public static function init(): void {
54
55 if ( self::$instance ) {
56 return;
57 }
58
59 self::$instance = new self();
60 }
61
62 /**
63 * Register repository and auth lifecycle hooks.
64 *
65 * @return void
66 */
67 protected function __construct() {
68
69 $this->keys_repository = new ApiKeysRepository();
70
71 add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 15 );
72 add_filter( 'rest_authentication_errors', array( $this, 'rest_authentication_errors' ), 15 );
73 add_filter( 'rest_post_dispatch', array( $this, 'rest_post_dispatch' ), 10, 3 );
74 }
75
76 /**
77 * Determine current user for MCP route using API key credentials.
78 *
79 * @param int|false $user_id Previously resolved user ID.
80 *
81 * @return int|false
82 */
83 public function determine_current_user( $user_id ) {
84 return $this->authenticate_request( $user_id );
85 }
86 /**
87 * Normalize auth errors for invalid API key attempts.
88 *
89 * @param WP_Error|null|bool $error Existing error from other authenticators.
90 *
91 * @return WP_Error|null|bool
92 */
93 public function rest_authentication_errors( $error ) {
94 if ( ! $this->is_target_rest_request && ! $this->is_target_rest_request() ) {
95 return $error;
96 }
97
98 if ( ! empty( $error ) ) {
99 return $error;
100 }
101
102 if ( ! AuthContext::is_api_key_auth() && ! ( $this->auth_error instanceof WP_Error ) ) {
103 $resolved_user_id = $this->authenticate_request( 0 );
104 if ( is_numeric( $resolved_user_id ) && (int) $resolved_user_id > 0 ) {
105 wp_set_current_user( (int) $resolved_user_id );
106 }
107 }
108
109 if ( $this->auth_error instanceof WP_Error ) {
110 return $this->auth_error;
111 }
112
113 if ( ! AuthContext::is_api_key_auth() ) {
114 return new WP_Error(
115 'learnpress_mcp_api_key_required',
116 __( 'MCP API key authentication is required.', 'learnpress' ),
117 array( 'status' => 401 )
118 );
119 }
120
121 return $error;
122 }
123
124 /**
125 * Attempt API-key authentication for current MCP request.
126 *
127 * @param int|false $user_id Previously resolved user ID.
128 *
129 * @return int|false
130 */
131 protected function authenticate_request( $user_id ) {
132
133 $this->auth_error = null;
134 $this->api_key_present = false;
135 $this->is_target_rest_request = $this->is_target_rest_request();
136 if ( ! $this->is_target_rest_request ) {
137 return $user_id;
138 }
139
140 AuthContext::reset();
141
142 $credentials = $this->parse_credentials();
143 if ( ! $credentials['present'] ) {
144 return $user_id;
145 }
146 $this->api_key_present = true;
147
148 $consumer_key = $credentials['consumer_key'];
149 $consumer_secret = $credentials['consumer_secret'];
150
151 if ( '' === $consumer_key || '' === $consumer_secret ) {
152 $this->auth_error = $this->invalid_credentials_error();
153 return 0;
154 }
155
156 $key = $this->keys_repository->find_by_consumer_key( $consumer_key );
157 if ( ! $key || empty( $key->consumer_secret ) || ! $this->keys_repository->verify_secret_hash( (string) $key->consumer_secret, $consumer_secret ) ) {
158 $this->auth_error = $this->invalid_credentials_error();
159 return 0;
160 }
161
162 $resolved_user_id = absint( $key->user_id );
163 if ( $resolved_user_id <= 0 || ! get_user_by( 'id', $resolved_user_id ) ) {
164 $this->auth_error = $this->invalid_credentials_error();
165 return 0;
166 }
167
168 AuthContext::set_api_key_auth(
169 absint( $key->key_id ),
170 $resolved_user_id,
171 (string) $key->permissions
172 );
173
174 return $resolved_user_id;
175 }
176
177 /**
178 * Post-dispatch behavior: update usage metrics for API-key-authenticated requests.
179 *
180 * @param mixed $result REST response object.
181 * @param mixed $server REST server instance.
182 * @param WP_REST_Request $request Request object.
183 *
184 * @return mixed
185 */
186 public function rest_post_dispatch( $result, $server, $request ) {
187 unset( $server );
188
189 if ( ! ( $request instanceof WP_REST_Request ) ) {
190 return $result;
191 }
192
193 if ( ! $this->is_target_route_from_request( $request ) ) {
194 return $result;
195 }
196
197 if ( AuthContext::is_api_key_auth() && ! AuthContext::is_usage_touched() ) {
198 $key_id = AuthContext::get_key_id();
199 if ( $key_id > 0 ) {
200 $this->keys_repository->touch_usage( $key_id );
201 AuthContext::mark_usage_touched();
202 }
203 }
204
205 return $result;
206 }
207 /**
208 * Parse API key credentials from query params or Basic auth.
209 *
210 * @return array<string, mixed>
211 */
212 protected function parse_credentials(): array {
213 $consumer_key_present = isset( $_GET['consumer_key'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
214 $consumer_secret_present = isset( $_GET['consumer_secret'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
215
216 $consumer_key = $consumer_key_present ? LP_Helper::sanitize_params_submitted( $_GET['consumer_key'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
217 $consumer_secret = $consumer_secret_present ? LP_Helper::sanitize_params_submitted( $_GET['consumer_secret'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
218
219 if ( $consumer_key_present || $consumer_secret_present ) {
220 return array(
221 'present' => true,
222 'consumer_key' => $consumer_key,
223 'consumer_secret' => $consumer_secret,
224 );
225 }
226
227 $has_php_auth_user = isset( $_SERVER['PHP_AUTH_USER'] );
228 $has_php_auth_pw = isset( $_SERVER['PHP_AUTH_PW'] );
229
230 if ( $has_php_auth_user || $has_php_auth_pw ) {
231 $basic_user = LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_USER'] ?? '' );
232 if ( ! $this->looks_like_consumer_key( $basic_user ) ) {
233 return array(
234 'present' => false,
235 'consumer_key' => '',
236 'consumer_secret' => '',
237 );
238 }
239
240 return array(
241 'present' => true,
242 'consumer_key' => $basic_user,
243 'consumer_secret' => LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_PW'] ?? '' ),
244 );
245 }
246
247 $authorization = $this->get_authorization_header();
248 if ( stripos( $authorization, 'Basic ' ) !== 0 ) {
249 return array(
250 'present' => false,
251 'consumer_key' => '',
252 'consumer_secret' => '',
253 );
254 }
255
256 $decoded = base64_decode( trim( substr( $authorization, 6 ) ), true );
257 if ( false === $decoded || strpos( $decoded, ':' ) === false ) {
258 return array(
259 'present' => true,
260 'consumer_key' => '',
261 'consumer_secret' => '',
262 );
263 }
264
265 list( $consumer_key, $consumer_secret ) = explode( ':', $decoded, 2 );
266 $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key, 'text', false );
267 if ( ! $this->looks_like_consumer_key( $consumer_key ) ) {
268 return array(
269 'present' => false,
270 'consumer_key' => '',
271 'consumer_secret' => '',
272 );
273 }
274
275 return array(
276 'present' => true,
277 'consumer_key' => $consumer_key,
278 'consumer_secret' => LP_Helper::sanitize_params_submitted( $consumer_secret, 'text', false ),
279 );
280 }
281
282 /**
283 * Read Authorization header from server/global headers.
284 *
285 * @return string
286 */
287 protected function get_authorization_header(): string {
288
289 $server_header_candidates = array(
290 'HTTP_AUTHORIZATION',
291 'REDIRECT_HTTP_AUTHORIZATION',
292 'REDIRECT_REDIRECT_HTTP_AUTHORIZATION',
293 );
294 foreach ( $server_header_candidates as $server_key ) {
295 if ( ! empty( $_SERVER[ $server_key ] ) ) {
296 return (string) wp_unslash( $_SERVER[ $server_key ] );
297 }
298 }
299
300 if ( function_exists( 'getallheaders' ) ) {
301 $headers = getallheaders();
302 if ( is_array( $headers ) ) {
303 foreach ( $headers as $key => $value ) {
304 if ( 'authorization' === strtolower( (string) $key ) ) {
305 return (string) $value;
306 }
307 }
308 }
309 }
310
311 if ( function_exists( 'apache_request_headers' ) ) {
312 $headers = apache_request_headers();
313 if ( is_array( $headers ) ) {
314 foreach ( $headers as $key => $value ) {
315 if ( 'authorization' === strtolower( (string) $key ) ) {
316 return (string) $value;
317 }
318 }
319 }
320 }
321
322 return '';
323 }
324
325 /**
326 * Whether current request targets the MCP default route.
327 *
328 * @return bool
329 */
330 protected function is_target_rest_request(): bool {
331 $rest_route = isset( $_GET['rest_route'] ) ? LP_Helper::sanitize_params_submitted( $_GET['rest_route'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
332 if ( '' !== $rest_route && $this->route_matches_mcp_target( $rest_route ) ) {
333 return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', true, $rest_route, self::MCP_ROUTE );
334 }
335 $request_uri = '';
336 if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
337 $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
338 }
339
340 if ( '' === $request_uri ) {
341 return false;
342 }
343
344 $rest_prefix = trailingslashit( rest_get_url_prefix() );
345 $is_mcp_target = false;
346 foreach ( $this->get_target_routes() as $route ) {
347 $target_path = $rest_prefix . ltrim( $route, '/' );
348 if ( false !== strpos( $request_uri, $target_path ) ) {
349 $is_mcp_target = true;
350 break;
351 }
352 }
353
354 return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', $is_mcp_target, $request_uri, self::MCP_ROUTE );
355 }
356 /**
357 * Whether a WP_REST_Request route is the MCP endpoint.
358 *
359 * @param WP_REST_Request $request Current REST request object.
360 *
361 * @return bool
362 */
363 protected function is_target_route_from_request( WP_REST_Request $request ): bool {
364
365 $route = (string) $request->get_route();
366
367 return $this->route_matches_mcp_target( $route );
368 }
369
370 /**
371 * Target routes that should use LearnPress MCP auth behavior.
372 *
373 * @return array<int, string>
374 */
375 protected function get_target_routes(): array {
376
377 return array(
378 self::MCP_ROUTE,
379 self::MCP_ALIAS_ROUTE,
380 );
381 }
382
383 /**
384 * Whether a route path matches one of MCP target routes.
385 *
386 * @param string $route Route path from request.
387 *
388 * @return bool
389 */
390 protected function route_matches_mcp_target( string $route ): bool {
391
392 foreach ( $this->get_target_routes() as $target_route ) {
393 if ( 0 === strpos( $route, $target_route ) ) {
394 return true;
395 }
396 }
397
398 return false;
399 }
400 /**
401 * Standardized invalid credentials error.
402 *
403 * @return WP_Error
404 */
405 protected function invalid_credentials_error(): WP_Error {
406
407 return new WP_Error(
408 'learnpress_mcp_invalid_api_key_credentials',
409 __( 'Invalid MCP API credentials.', 'learnpress' ),
410 array( 'status' => 401 )
411 );
412 }
413
414 /**
415 * Validate expected consumer key format.
416 *
417 * @param string $consumer_key Plaintext consumer key.
418 *
419 * @return bool
420 */
421 protected function looks_like_consumer_key( string $consumer_key ): bool {
422
423 return 1 === preg_match( '/^ck_[a-f0-9]{40}$/', $consumer_key );
424 }
425 }
426