PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.1
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.4.1, at inc/MCP/Auth/ApiKeyAuthenticator.php

413 lines 10.2 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
214 $has_php_auth_user = isset( $_SERVER['PHP_AUTH_USER'] );
215 $has_php_auth_pw = isset( $_SERVER['PHP_AUTH_PW'] );
216
217 if ( $has_php_auth_user || $has_php_auth_pw ) {
218 $basic_user = LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_USER'] ?? '' );
219 if ( ! $this->looks_like_consumer_key( $basic_user ) ) {
220 return array(
221 'present' => false,
222 'consumer_key' => '',
223 'consumer_secret' => '',
224 );
225 }
226
227 return array(
228 'present' => true,
229 'consumer_key' => $basic_user,
230 'consumer_secret' => LP_Helper::sanitize_params_submitted( $_SERVER['PHP_AUTH_PW'] ?? '' ),
231 );
232 }
233
234 $authorization = $this->get_authorization_header();
235 if ( stripos( $authorization, 'Basic ' ) !== 0 ) {
236 return array(
237 'present' => false,
238 'consumer_key' => '',
239 'consumer_secret' => '',
240 );
241 }
242
243 $decoded = base64_decode( trim( substr( $authorization, 6 ) ), true );
244 if ( false === $decoded || strpos( $decoded, ':' ) === false ) {
245 return array(
246 'present' => true,
247 'consumer_key' => '',
248 'consumer_secret' => '',
249 );
250 }
251
252 list( $consumer_key, $consumer_secret ) = explode( ':', $decoded, 2 );
253 $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key, 'text', false );
254 if ( ! $this->looks_like_consumer_key( $consumer_key ) ) {
255 return array(
256 'present' => false,
257 'consumer_key' => '',
258 'consumer_secret' => '',
259 );
260 }
261
262 return array(
263 'present' => true,
264 'consumer_key' => $consumer_key,
265 'consumer_secret' => LP_Helper::sanitize_params_submitted( $consumer_secret, 'text', false ),
266 );
267 }
268
269 /**
270 * Read Authorization header from server/global headers.
271 *
272 * @return string
273 */
274 protected function get_authorization_header(): string {
275
276 $server_header_candidates = array(
277 'HTTP_AUTHORIZATION',
278 'REDIRECT_HTTP_AUTHORIZATION',
279 'REDIRECT_REDIRECT_HTTP_AUTHORIZATION',
280 );
281 foreach ( $server_header_candidates as $server_key ) {
282 if ( ! empty( $_SERVER[ $server_key ] ) ) {
283 return (string) wp_unslash( $_SERVER[ $server_key ] );
284 }
285 }
286
287 if ( function_exists( 'getallheaders' ) ) {
288 $headers = getallheaders();
289 if ( is_array( $headers ) ) {
290 foreach ( $headers as $key => $value ) {
291 if ( 'authorization' === strtolower( (string) $key ) ) {
292 return (string) $value;
293 }
294 }
295 }
296 }
297
298 if ( function_exists( 'apache_request_headers' ) ) {
299 $headers = apache_request_headers();
300 if ( is_array( $headers ) ) {
301 foreach ( $headers as $key => $value ) {
302 if ( 'authorization' === strtolower( (string) $key ) ) {
303 return (string) $value;
304 }
305 }
306 }
307 }
308
309 return '';
310 }
311
312 /**
313 * Whether current request targets the MCP default route.
314 *
315 * @return bool
316 */
317 protected function is_target_rest_request(): bool {
318 $rest_route = isset( $_GET['rest_route'] ) ? LP_Helper::sanitize_params_submitted( $_GET['rest_route'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
319 if ( '' !== $rest_route && $this->route_matches_mcp_target( $rest_route ) ) {
320 return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', true, $rest_route, self::MCP_ROUTE );
321 }
322 $request_uri = '';
323 if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
324 $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
325 }
326
327 if ( '' === $request_uri ) {
328 return false;
329 }
330
331 $rest_prefix = trailingslashit( rest_get_url_prefix() );
332 $is_mcp_target = false;
333 foreach ( $this->get_target_routes() as $route ) {
334 $target_path = $rest_prefix . ltrim( $route, '/' );
335 if ( false !== strpos( $request_uri, $target_path ) ) {
336 $is_mcp_target = true;
337 break;
338 }
339 }
340
341 return (bool) apply_filters( 'learn-press/mcp/api-keys/is-target-rest-request', $is_mcp_target, $request_uri, self::MCP_ROUTE );
342 }
343 /**
344 * Whether a WP_REST_Request route is the MCP endpoint.
345 *
346 * @param WP_REST_Request $request Current REST request object.
347 *
348 * @return bool
349 */
350 protected function is_target_route_from_request( WP_REST_Request $request ): bool {
351
352 $route = (string) $request->get_route();
353
354 return $this->route_matches_mcp_target( $route );
355 }
356
357 /**
358 * Target routes that should use LearnPress MCP auth behavior.
359 *
360 * @return array<int, string>
361 */
362 protected function get_target_routes(): array {
363
364 return array(
365 self::MCP_ROUTE,
366 self::MCP_ALIAS_ROUTE,
367 );
368 }
369
370 /**
371 * Whether a route path matches one of MCP target routes.
372 *
373 * @param string $route Route path from request.
374 *
375 * @return bool
376 */
377 protected function route_matches_mcp_target( string $route ): bool {
378
379 foreach ( $this->get_target_routes() as $target_route ) {
380 if ( 0 === strpos( $route, $target_route ) ) {
381 return true;
382 }
383 }
384
385 return false;
386 }
387 /**
388 * Standardized invalid credentials error.
389 *
390 * @return WP_Error
391 */
392 protected function invalid_credentials_error(): WP_Error {
393
394 return new WP_Error(
395 'learnpress_mcp_invalid_api_key_credentials',
396 __( 'Invalid MCP API credentials.', 'learnpress' ),
397 array( 'status' => 401 )
398 );
399 }
400
401 /**
402 * Validate expected consumer key format.
403 *
404 * @param string $consumer_key Plaintext consumer key.
405 *
406 * @return bool
407 */
408 protected function looks_like_consumer_key( string $consumer_key ): bool {
409
410 return 1 === preg_match( '/^ck_[a-f0-9]{40}$/', $consumer_key );
411 }
412 }
413