PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.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 All 139 releases
learnpress / inc / MCP / Auth / ApiKeyAuthenticator.php

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

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