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 / ApiKeysRepository.php

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

518 lines 13.3 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_Database;
6 use LP_Filter;
7 use LP_Helper;
8 use LearnPress\Databases\UserDB;
9 use LearnPress\Filters\UserFilter;
10 use Exception;
11
12 defined( 'ABSPATH' ) || exit;
13
14 /**
15 * Handles persistence and credential lifecycle for LearnPress MCP API keys.
16 */
17 class ApiKeysRepository {
18 /**
19 * Allowed key permission values.
20 */
21 public const PERMISSIONS = array( 'read', 'write', 'read_write' );
22
23 /**
24 * @var \wpdb
25 */
26 protected $wpdb;
27
28 /**
29 * @var string
30 */
31 protected $table;
32
33 /**
34 * @var string
35 */
36 protected $users_table;
37
38 /**
39 * Initialize DB handles for MCP API key storage.
40 *
41 * @return void
42 */
43 public function __construct() {
44 global $wpdb;
45
46 $this->wpdb = $wpdb;
47 $this->table = LP_Database::getInstance()->tb_lp_mcp_api_keys;
48 $this->users_table = $wpdb->users;
49 }
50
51 /**
52 * Create a new API key and return plaintext credentials once.
53 *
54 * @param int $user_id User ID that owns the key.
55 * @param string $description Optional key description.
56 * @param string $permissions Key permission.
57 *
58 * @return array<string, mixed>|null
59 * @throws Exception
60 */
61 public function create_key( int $user_id, string $description = '', string $permissions = 'read' ): ?array {
62 $user_id = absint( $user_id );
63 if ( $user_id <= 0 || ! $this->is_valid_user_id( $user_id ) ) {
64 return null;
65 }
66
67 $permissions = $this->normalize_permissions( $permissions );
68 $description = $this->normalize_description( $description );
69 $consumer_key = $this->generate_token( 'ck_' );
70 $consumer_secret = $this->generate_token( 'cs_' );
71 $created_at = current_time( 'mysql', true );
72
73 $inserted = $this->wpdb->insert(
74 $this->table,
75 array(
76 'user_id' => $user_id,
77 'description' => $description,
78 'permissions' => $permissions,
79 'consumer_key' => self::hash_consumer_key( $consumer_key ),
80 'consumer_secret' => self::hash_consumer_secret( $consumer_secret ),
81 'truncated_key' => substr( $consumer_key, -7 ),
82 'created_at' => $created_at,
83 ),
84 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
85 );
86
87 if ( ! $inserted ) {
88 return null;
89 }
90
91 return array(
92 'key_id' => (int) $this->wpdb->insert_id,
93 'user_id' => $user_id,
94 'description' => $description,
95 'permissions' => $permissions,
96 'consumer_key' => $consumer_key,
97 'consumer_secret' => $consumer_secret,
98 'truncated_key' => substr( $consumer_key, -7 ),
99 'created_at' => $created_at,
100 );
101 }
102
103 /**
104 * Update mutable metadata for an API key.
105 *
106 * @param int $key_id Key ID.
107 * @param int $user_id New owner user ID.
108 * @param string $description New key description.
109 * @param string $permissions New key permissions.
110 *
111 * @return bool
112 */
113 public function update_key_meta( int $key_id, int $user_id, string $description, string $permissions ): bool {
114 $key_id = absint( $key_id );
115 $user_id = absint( $user_id );
116
117 if ( $key_id <= 0 || $user_id <= 0 || ! $this->is_valid_user_id( $user_id ) ) {
118 return false;
119 }
120
121 $updated = $this->wpdb->update(
122 $this->table,
123 array(
124 'user_id' => $user_id,
125 'description' => $this->normalize_description( $description ),
126 'permissions' => $this->normalize_permissions( $permissions ),
127 'updated_at' => current_time( 'mysql', true ),
128 ),
129 array( 'key_id' => $key_id ),
130 array( '%d', '%s', '%s', '%s' ),
131 array( '%d' )
132 );
133
134 return false !== $updated;
135 }
136
137 /**
138 * Rotate consumer key and secret for an existing key.
139 *
140 * @param int $key_id Key ID.
141 *
142 * @return array<string, mixed>|null
143 */
144 public function regenerate_key( int $key_id ): ?array {
145 $key_id = absint( $key_id );
146 if ( $key_id <= 0 ) {
147 return null;
148 }
149
150 $row = $this->get_key( $key_id );
151 if ( ! $row ) {
152 return null;
153 }
154
155 $consumer_key = $this->generate_token( 'ck_' );
156 $consumer_secret = $this->generate_token( 'cs_' );
157 $updated_at = current_time( 'mysql', true );
158
159 $updated = $this->wpdb->update(
160 $this->table,
161 array(
162 'consumer_key' => self::hash_consumer_key( $consumer_key ),
163 'consumer_secret' => self::hash_consumer_secret( $consumer_secret ),
164 'truncated_key' => substr( $consumer_key, -7 ),
165 'updated_at' => $updated_at,
166 ),
167 array( 'key_id' => $key_id ),
168 array( '%s', '%s', '%s', '%s' ),
169 array( '%d' )
170 );
171
172 if ( false === $updated ) {
173 return null;
174 }
175
176 return array(
177 'key_id' => $key_id,
178 'user_id' => (int) $row->user_id,
179 'description' => (string) $row->description,
180 'permissions' => (string) $row->permissions,
181 'consumer_key' => $consumer_key,
182 'consumer_secret' => $consumer_secret,
183 'truncated_key' => substr( $consumer_key, -7 ),
184 'updated_at' => $updated_at,
185 );
186 }
187
188 /**
189 * Revoke (delete) one key.
190 *
191 * @param int $key_id Key ID.
192 *
193 * @return bool
194 */
195 public function revoke_key( int $key_id ): bool {
196 $key_id = absint( $key_id );
197 if ( $key_id <= 0 ) {
198 return false;
199 }
200
201 $deleted = $this->wpdb->delete( $this->table, array( 'key_id' => $key_id ), array( '%d' ) );
202
203 return false !== $deleted;
204 }
205
206 /**
207 * Revoke multiple keys.
208 *
209 * @param array<int, int|string> $key_ids Key IDs to revoke.
210 *
211 * @return int
212 */
213 public function revoke_keys( array $key_ids ): int {
214 $key_ids = array_values( array_filter( array_map( 'absint', $key_ids ) ) );
215 if ( empty( $key_ids ) ) {
216 return 0;
217 }
218
219 $sql = $this->wpdb->prepare(
220 "DELETE FROM {$this->table} WHERE key_id IN (" . LP_Helper::db_format_array( $key_ids, '%d' ) . ')',
221 $key_ids
222 );
223
224 $deleted = $this->wpdb->query( $sql );
225
226 return $deleted > 0 ? (int) $deleted : 0;
227 }
228
229 /**
230 * Find key row by plaintext consumer key.
231 *
232 * @param string $consumer_key Plaintext consumer key.
233 *
234 * @return object|null
235 */
236 public function find_by_consumer_key( string $consumer_key ) {
237 $consumer_key = LP_Helper::sanitize_params_submitted( $consumer_key );
238 if ( '' === $consumer_key ) {
239 return null;
240 }
241
242 $filter = new LP_Filter();
243 $filter->collection = $this->table;
244 $filter->collection_alias = 'k';
245 $filter->only_fields = array( 'k.*' );
246 $filter->where[] = $this->wpdb->prepare( 'AND k.consumer_key = %s', self::hash_consumer_key( $consumer_key ) );
247 $filter->limit = 1;
248 $filter->field_count = 'k.key_id';
249 $filter->run_query_count = false;
250
251 $total_rows = 0;
252 $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
253 if ( ! is_array( $rows ) || empty( $rows ) ) {
254 return null;
255 }
256
257 return reset( $rows );
258 }
259
260 /**
261 * Get a key row by key ID.
262 *
263 * @param int $key_id Key ID.
264 *
265 * @return object|null
266 */
267 public function get_key( int $key_id ) {
268 $key_id = absint( $key_id );
269 if ( $key_id <= 0 ) {
270 return null;
271 }
272
273 $filter = new LP_Filter();
274 $filter->collection = $this->table;
275 $filter->collection_alias = 'k';
276 $filter->only_fields = array( 'k.*' );
277 $filter->where[] = $this->wpdb->prepare( 'AND k.key_id = %d', $key_id );
278 $filter->limit = 1;
279 $filter->field_count = 'k.key_id';
280 $filter->run_query_count = false;
281
282 $total_rows = 0;
283 $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
284 if ( ! is_array( $rows ) || empty( $rows ) ) {
285 return null;
286 }
287
288 return reset( $rows );
289 }
290
291 /**
292 * Check whether raw secret matches stored secret hash.
293 *
294 * @param string $stored_hash Stored secret hash from database.
295 * @param string $provided_secret Raw secret provided by request.
296 *
297 * @return bool
298 */
299 public function verify_secret_hash( string $stored_hash, string $provided_secret ): bool {
300 $provided_hash = self::hash_consumer_secret( $provided_secret );
301
302 return hash_equals( $stored_hash, $provided_hash );
303 }
304
305 /**
306 * Update key usage metrics.
307 *
308 * @param int $key_id Key ID.
309 *
310 * @return void
311 */
312 public function touch_usage( int $key_id ): void {
313 $key_id = absint( $key_id );
314 if ( $key_id <= 0 ) {
315 return;
316 }
317
318 $now = current_time( 'mysql', true );
319 $sql = $this->wpdb->prepare(
320 "UPDATE {$this->table} SET last_access = %s, call_count = call_count + 1 WHERE key_id = %d",
321 $now,
322 $key_id
323 );
324
325 $this->wpdb->query( $sql );
326 }
327
328 /**
329 * Query keys list for admin table.
330 *
331 * @param array<string, mixed> $args Query arguments.
332 *
333 * @return array<string, mixed>
334 */
335 public function query_keys( array $args = array() ): array {
336 $args = wp_parse_args(
337 $args,
338 array(
339 'page' => 1,
340 'per_page' => 20,
341 'search' => '',
342 'user_id' => 0,
343 'orderby' => 'created_at',
344 'order' => 'DESC',
345 )
346 );
347
348 $page = max( 1, absint( $args['page'] ) );
349 $per_page = max( 1, min( 100, absint( $args['per_page'] ) ) );
350
351 $filter = new LP_Filter();
352 $filter->collection = $this->table;
353 $filter->collection_alias = 'k';
354 $filter->only_fields = array(
355 'k.*',
356 'u.display_name AS user_display_name',
357 'u.user_login',
358 );
359 $filter->join[] = "LEFT JOIN {$this->users_table} u ON u.ID = k.user_id";
360 $filter->field_count = 'k.key_id';
361 $filter->limit = $per_page;
362 $filter->page = $page;
363
364 $search = LP_Helper::sanitize_params_submitted( (string) $args['search'] );
365 if ( '' !== $search ) {
366 $filter->where[] = $this->wpdb->prepare( 'AND k.description LIKE %s', '%' . $this->wpdb->esc_like( $search ) . '%' );
367 }
368
369 $user_id = absint( $args['user_id'] );
370 if ( $user_id > 0 ) {
371 $filter->where[] = $this->wpdb->prepare( 'AND k.user_id = %d', $user_id );
372 }
373
374 $order_by_map = array(
375 'description' => 'k.description',
376 'user' => 'u.display_name',
377 'permissions' => 'k.permissions',
378 'last_access' => 'k.last_access',
379 'call_count' => 'k.call_count',
380 'created_at' => 'k.created_at',
381 );
382 $filter->order_by = $order_by_map[ $args['orderby'] ] ?? $order_by_map['created_at'];
383 $filter->order = 'ASC' === strtoupper( (string) $args['order'] ) ? LP_Filter::ORDER_ASC : LP_Filter::ORDER_DESC;
384
385 $total_items = 0;
386 $items = LP_Database::getInstance()->execute( $filter, $total_items );
387
388 return array(
389 'items' => is_array( $items ) ? $items : array(),
390 'total' => $total_items,
391 'page' => $page,
392 'per_page' => $per_page,
393 );
394 }
395
396 /**
397 * Return users currently owning MCP API keys.
398 *
399 * @return array<int, object>
400 */
401 public function users_with_keys(): array {
402 $filter = new LP_Filter();
403 $filter->collection = $this->users_table;
404 $filter->collection_alias = 'u';
405 $filter->only_fields = array(
406 'u.ID',
407 'u.user_login',
408 'u.display_name',
409 );
410 $filter->join[] = "INNER JOIN {$this->table} k ON u.ID = k.user_id";
411 $filter->group_by = 'u.ID';
412 $filter->order_by = 'u.display_name';
413 $filter->order = LP_Filter::ORDER_ASC;
414 $filter->limit = -1;
415 $filter->run_query_count = false;
416
417 $total_rows = 0;
418 $rows = LP_Database::getInstance()->execute( $filter, $total_rows );
419
420 return is_array( $rows ) ? $rows : array();
421 }
422
423 /**
424 * Hash plaintext consumer key for storage/lookup.
425 *
426 * @param string $consumer_key Plaintext consumer key.
427 *
428 * @return string
429 */
430 public static function hash_consumer_key( string $consumer_key ): string {
431 return hash_hmac( 'sha256', $consumer_key, 'lp-mcp-api' );
432 }
433
434 /**
435 * Hash plaintext consumer secret for storage/verification.
436 *
437 * @param string $consumer_secret Plaintext consumer secret.
438 *
439 * @return string
440 */
441 public static function hash_consumer_secret( string $consumer_secret ): string {
442 return hash_hmac( 'sha256', $consumer_secret, 'lp-mcp-secret' );
443 }
444
445 /**
446 * Normalize description for DB storage.
447 *
448 * @param string $description Raw key description.
449 *
450 * @return string
451 */
452 protected function normalize_description( string $description ): string {
453 $description = LP_Helper::sanitize_params_submitted( $description );
454
455 return function_exists( 'mb_substr' ) ? mb_substr( $description, 0, 200 ) : substr( $description, 0, 200 );
456 }
457
458 /**
459 * Normalize requested permission to a supported value.
460 *
461 * @param string $permissions Raw requested permission.
462 *
463 * @return string
464 */
465 protected function normalize_permissions( string $permissions ): string {
466 $permissions = LP_Helper::sanitize_params_submitted( $permissions, 'key' );
467
468 if ( ! in_array( $permissions, self::PERMISSIONS, true ) ) {
469 $permissions = 'read';
470 }
471
472 return $permissions;
473 }
474
475 /**
476 * Generate token in ck_/cs_ format.
477 *
478 * @param string $prefix Token prefix (`ck_` or `cs_`).
479 *
480 * @return string
481 */
482 protected function generate_token( string $prefix ): string {
483 try {
484 $hex = bin2hex( random_bytes( 20 ) );
485 } catch ( Exception $e ) {
486 $hex = substr( hash( 'sha256', wp_generate_password( 64, true, true ) . microtime( true ) ), 0, 40 );
487 }
488
489 return $prefix . $hex;
490 }
491
492 /**
493 * Validate user existence via LearnPress DB + Filter classes.
494 *
495 * @param int $user_id User ID.
496 *
497 * @return bool
498 * @throws Exception
499 */
500 protected function is_valid_user_id( int $user_id ): bool {
501 $user_id = absint( $user_id );
502 if ( $user_id <= 0 ) {
503 return false;
504 }
505
506 $filter = new UserFilter();
507 $filter->ID = $user_id;
508 $filter->limit = 1;
509 $filter->only_fields = array( 'u.ID' );
510 $filter->field_count = 'u.ID';
511
512 $total_rows = 0;
513 $rows = UserDB::getInstance()->get_users( $filter, $total_rows );
514
515 return is_array( $rows ) && ! empty( $rows );
516 }
517 }
518