SupportUser.php
20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
<?php
/**
* Class SupportUser
*
* @package ContentControl\Vendor\TrustedLogin\Client
*
* @copyright 2021 Katz Web Services, Inc.
*
* @license GPL-2.0-or-later
* Modified by code-atlantic on 21-June-2024 using {@see https://github.com/BrianHenryIE/strauss}.
*/
namespace ContentControl\Vendor\TrustedLogin;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use \Exception;
use \WP_Error;
use \WP_User;
use \WP_Admin_Bar;
/**
* The TrustedLogin all-in-one drop-in class.
*/
final class SupportUser {
/**
* @var string The query parameter used to pass the unique user ID
*/
const ID_QUERY_PARAM = 'tlid';
/**
* @var Config $config
*/
private $config;
/**
* @var Logging $logging
*/
private $logging;
/**
* @var SupportRole $role
*/
public $role;
/**
* @var string $user_identifier_meta_key The namespaced setting name for storing the unique identifier hash in user meta
* @since 1.0.0
* @example tl_{vendor/namespace}_id
*/
private $user_identifier_meta_key;
/**
* @var string $site_hash_meta_key The namespaced setting name for storing the site identifier hash in user meta
* @since 1.0.0
* @example tl_{vendor/namespace}_site_hash
*/
private $site_hash_meta_key;
/**
* @var int $expires_meta_key The namespaced setting name for storing the timestamp the user expires
* @since 1.0.0
* @example tl_{vendor/namespace}_expires
*/
private $expires_meta_key;
/**
* @var int $created_by_meta_key The ID of the user who created the TrustedLogin access
* @since 1.0.0
*/
private $created_by_meta_key;
/**
* SupportUser constructor.
*/
public function __construct( Config $config, Logging $logging ) {
$this->config = $config;
$this->logging = $logging;
$this->role = new SupportRole( $config, $logging );
$this->user_identifier_meta_key = 'tl_' . $config->ns() . '_id';
$this->site_hash_meta_key = 'tl_' . $config->ns() . '_site_hash';
$this->expires_meta_key = 'tl_' . $config->ns() . '_expires';
$this->created_by_meta_key = 'tl_' . $config->ns() . '_created_by';
}
/**
* Allow accessing limited private properties with a magic method.
*
* @param string $name Name of property
*
* @return string|null Value of property, if defined. Otherwise, null.
*/
public function __get( $name ) {
// Allow accessing limited private variables
switch ( $name ) {
case 'identifier_meta_key':
case 'expires_meta_key':
case 'created_by_meta_key':
return $this->{$name};
}
return null;
}
/**
* Checks if a Support User for this vendor has already been created.
*
* @since 1.0.0
*
* @return int|false - WP User ID if support user exists, otherwise false.
*/
public function exists() {
$args = array(
'number' => 1,
'meta_key' => $this->user_identifier_meta_key,
'meta_value' => '',
'meta_compare' => 'EXISTS',
'fields' => 'ID',
);
$user_ids = get_users( $args );
return empty( $user_ids ) ? false : (int) $user_ids[0];
}
/**
* Returns whether the support user exists and has an expiration time in the future.
*
* @since 1.0.2
*
* @return bool True: Support user exists and has an expiration time in the future. False: Any of those things aren't true.
*/
public function is_active( $passed_user = null ) {
$current_user = is_a( $passed_user, '\WP_User' ) ? $passed_user : wp_get_current_user();
if ( ! $current_user || ! $current_user->exists() ) {
return false;
}
$expiration = $this->get_expiration( $current_user, false, true );
if ( ! $expiration ) {
return false;
}
if ( time() > (int) $expiration ) {
return false;
}
return true;
}
/**
* Create the Support User.
*
* @since 1.0.0
*
* @uses wp_insert_user()
*
* @return int|WP_Error - Array with login response information if created, or WP_Error object if there was an issue.
*/
public function create() {
$user_id = $this->exists();
// Double-check that a user doesn't exist before trying to create a new one.
if ( $user_id ) {
$this->logging->log( 'Support User not created; already exists: User #' . $user_id, __METHOD__, 'notice' );
return new \WP_Error( 'user_exists', sprintf( 'A user with the User ID %d already exists', $user_id ) );
}
$role = $this->role->get();
if ( is_wp_error( $role ) ) {
return $role;
}
$user_email = $this->config->get_setting( 'vendor/email' );
$allow_existing_user_match = false; // Fail if the user already exists and the email is unhashed.
if ( defined( 'LOGGED_IN_KEY' ) && defined( 'NONCE_KEY' ) ) {
// The hash doesn't need to be secure, just persistent.
$user_email = str_replace( '{hash}', sha1( LOGGED_IN_KEY . NONCE_KEY . get_current_blog_id() ), $user_email );
$allow_existing_user_match = true; // Don't fail if the user already exists and the email matches the hash.
}
$user_id_of_email = email_exists( $user_email );
if ( $user_id_of_email ) {
$this->logging->log( 'Support User not created; a user with that email already exists: ' . $user_email, __METHOD__, 'warning' );
// Only allow the user to be created if the email is not hashed; that way, it's not possible to accidentally
// create a user with the same email as an existing user.
if ( ! $allow_existing_user_match ) {
return new \WP_Error( 'email_exists', esc_html__( 'User not created; User with that email already exists', 'trustedlogin' ) );
}
// If the user already exists and the email matches the hash, use that user.
return $user_id_of_email;
}
$user_data = array(
'user_login' => $this->generate_unique_username(),
'user_email' => $user_email,
'user_pass' => Encryption::get_random_hash( $this->logging ),
'role' => $role->name,
'display_name' => $this->config->get_setting( 'vendor/display_name', '' ),
'user_registered' => date( 'Y-m-d H:i:s', time() ),
);
$new_user_id = wp_insert_user( $user_data );
if ( is_wp_error( $new_user_id ) ) {
$this->logging->log( 'Error: User not created because: ' . $new_user_id->get_error_message(), __METHOD__, 'error' );
return $new_user_id;
}
$this->logging->log( 'Support User #' . $new_user_id, __METHOD__, 'info' );
return $new_user_id;
}
/**
* Always return a unique username
*
* @return string Username, with possible number trailing, if clashes exist.
*/
private function generate_unique_username() {
// translators: %s is replaced with the name of the software developer (e.g. "Acme Widgets")
$username = sprintf( esc_html__( '%s Support', 'trustedlogin' ), $this->config->get_setting( 'vendor/title' ) );
if ( ! username_exists( $username ) ) {
return $username;
}
$i = 1;
$new_username = $username;
while ( username_exists( $new_username ) ) {
$new_username = sprintf( '%s %d', $username, $i + 1 );
}
return $new_username;
}
/**
* Returns the site secret ID connected to the support user.
*
* @param string $user_identifier
*
* @return string|WP_Error|null Returns the secret ID. WP_Error if there was a problem generating any hashes. Null: No users were found using that user identifier.
*/
public function get_secret_id( $user_identifier ) {
$user = $this->get( $user_identifier );
if ( is_null( $user ) ) {
return null;
}
$site_identifier_hash = $this->get_site_hash( $user );
if ( is_wp_error( $site_identifier_hash ) ) {
return $site_identifier_hash;
}
$Endpoint = new Endpoint( $this->config, $this->logging );
return $Endpoint->generate_secret_id( $site_identifier_hash );
}
/**
* Logs in a support user, if any exist at $user_identifier and haven't expired yet
*
* If the user access has expired, deletes the user with {@see SupportUser::delete()}
*
* @param string $user_identifier Unique identifier for support user before being hashed.
*
* @return true|WP_Error
*/
public function maybe_login( $user_identifier ) {
$support_user = $this->get( $user_identifier );
if ( empty( $support_user ) ) {
$this->logging->log( 'Support user not found at identifier ' . esc_attr( $user_identifier ), __METHOD__, 'notice' );
return new \WP_Error( 'user_not_found', sprintf( 'Support user not found at identifier %s.', esc_attr( $user_identifier ) ) );
}
$is_active = $this->is_active( $support_user );
// This user has expired, but the cron didn't run...
if ( ! $is_active ) {
$expires = $this->get_expiration( $support_user, false, true );
$this->logging->log( 'The user was supposed to expire on ' . $expires . '; revoking now.', __METHOD__, 'warning' );
$this->delete( $user_identifier, true, true );
return new \WP_Error( 'access_expired', 'The user was supposed to expire on ' . $expires . '; revoking now.' );
}
$this->login( $support_user );
return true;
}
/**
* Processes login (with extra logging) and triggers the 'trustedlogin/{ns}/login' hook
*
* @param \WP_User $support_user
*/
private function login( \WP_User $support_user ) {
if ( ! $support_user->exists() ) {
$this->logging->log( sprintf( 'Login failed: Support User #%d does not exist.', $support_user->ID ), __METHOD__, 'error' );
return;
}
wp_set_current_user( $support_user->ID, $support_user->user_login );
wp_set_auth_cookie( $support_user->ID );
do_action( 'wp_login', $support_user->user_login, $support_user );
$this->logging->log( sprintf( 'Support User #%d logged in', $support_user->ID ), __METHOD__, 'notice' );
/**
* Action run when TrustedLogin has logged-in
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/logged_in', array(
'url' => get_site_url(),
'action' => 'logged_in',
) );
}
/**
* Helper Function: Get the generated support user(s).
*
* @since 1.0.0
*
* @param string $user_identifier_or_hash
*
* @return \WP_User|null WP_User if found; null if not
*/
public function get( $user_identifier_or_hash = '' ) {
if ( empty( $user_identifier_or_hash ) ) {
return null;
}
$user_identifier_hash = $user_identifier_or_hash;
// When passed in the endpoint URL, the unique ID will be the raw value, not the hash.
if ( strlen( $user_identifier_or_hash ) > 32 ) {
$user_identifier_hash = Encryption::hash( $user_identifier_or_hash );
}
$args = array(
'number' => 1,
'meta_key' => $this->user_identifier_meta_key,
'meta_value' => $user_identifier_hash,
);
$user = get_users( $args );
return empty( $user ) ? null : $user[0];
}
/**
* Returns the expiration for user access as either a human-readable string or timestamp.
*
* @param \WP_User $user
* @param bool $human_readable Whether to show expiration as a human_time_diff()-formatted string. Default: false.
* @param bool $gmt Whether to use GMT timestamp in the human-readable result. Not used if $human_readable is false. Default: false.
*
* @return int|string|false False if no expiration is set. Expiration timestamp if $human_readable is false. Time diff if $human_readable is true.
*/
public function get_expiration( \WP_User $user, $human_readable = false, $gmt = false ) {
$expiration = get_user_option( $this->expires_meta_key, $user->ID );
if ( ! $expiration ) {
return false;
}
return $human_readable ? human_time_diff( current_time( 'timestamp', $gmt ), $expiration ) : $expiration;
}
/**
* Get all users with the support role.
*
* @since 1.0.0
*
* @return \WP_User[]
*/
public function get_all() {
static $support_users = null;
// Only fetch once per process
if ( ! is_null( $support_users ) ) {
return $support_users;
}
$args = array(
'number' => - 1,
'meta_key' => $this->user_identifier_meta_key,
'meta_compare' => 'EXISTS',
'meta_value' => '',
);
$support_users = get_users( $args );
return $support_users;
}
/**
* Returns the first support user active on the site, if any.
*
* @since 1.0.0
*
* @return \WP_User|null
*/
public function get_first() {
$support_users = $this->get_all();
if ( $support_users ) {
return $support_users[0];
}
return null;
}
/**
* Deletes support user(s) with options to delete the TrustedLogin-created user role and endpoint as well
*
* @used-by SupportUser::maybe_login() Called when user access has expired, but the cron didn't run...
* @used-by Client::revoke_access()
*
* @param string $user_identifier Unique identifier of the user to delete.
* @param bool $delete_role Should the TrustedLogin-created user role be deleted also? Default: `true`.
* @param bool $delete_endpoint Should the TrustedLogin endpoint for the site be deleted also? Default: `true`.
*
* @return bool|WP_Error True: Successfully removed user and role; false: There are no support users matching $user_identifier; WP_Error: something went wrong.
*/
public function delete( $user_identifier = '', $delete_role = true, $delete_endpoint = true ) {
require_once ABSPATH . 'wp-admin/includes/user.php'; // Needed for wp_delete_user()
$user = $this->get( $user_identifier );
if ( empty( $user ) ) {
return false;
}
$reassign_id_or_null = $this->get_reassign_user_id();
$this->logging->log( 'Processing user ID ' . $user->ID, __METHOD__, 'debug' );
// Remove auto-cleanup hook
wp_clear_scheduled_hook( 'trustedlogin/' . $this->config->ns() . '/access/revoke', array( $user_identifier ) );
// Delete first using wp_delete_user() to allow for reassignment of posts
$deleted = wp_delete_user( $user->ID, $reassign_id_or_null );
// Also delete the user from the all sites on the WP Multisite network
$wpmu_deleted = \function_exists( 'wpmu_delete_user' ) ? wpmu_delete_user( $user->ID ) : false;
if ( $deleted ) {
$message = 'User: ' . $user->ID . ' deleted.';
if ( $wpmu_deleted ) {
$message .= ' Also deleted from the Multisite network.';
}
$this->logging->log( $message, __METHOD__, 'info' );
} else {
$this->logging->log( 'User: ' . $user->ID . ' was NOT deleted.', __METHOD__, 'error' );
}
if ( $delete_role ) {
$this->role->delete();
}
if ( $delete_endpoint ) {
$Endpoint = new Endpoint( $this->config, $this->logging );
$Endpoint->delete();
}
// Re-run to make sure there were no race conditions
return $this->delete( $user_identifier );
}
/**
* Get the ID of the best-guess appropriate admin user
*
* @since 1.0.0
*
* @return int|null User ID if there are admins, null if not
*/
private function get_reassign_user_id() {
if ( ! $this->config->get_setting( 'reassign_posts' ) ) {
return null;
}
// TODO: Add a filter to modify who gets auto-reassigned
$admins = get_users( array(
'role' => 'administrator',
'orderby' => 'registered',
'order' => 'DESC',
'number' => 1,
) );
$reassign_id = empty( $admins ) ? null : $admins[0]->ID;
$this->logging->log( 'Reassign user ID: ' . var_export( $reassign_id, true ), __METHOD__, 'info' );
return $reassign_id;
}
/**
* Schedules cron job to auto-revoke, adds user meta with unique ids
*
* @param int $user_id ID of generated support user
* @param string $site_identifier_hash
* @param int $decay_timestamp Timestamp when user will be removed
*
* @return string|WP_Error Value of $identifier_meta_key if worked; empty string or WP_Error if not.
*/
public function setup( $user_id, $site_identifier_hash, $expiration_timestamp = null, Cron $cron = null ) {
if ( $expiration_timestamp ) {
$scheduled = $cron->schedule( $expiration_timestamp, $site_identifier_hash );
if ( $scheduled ) {
update_user_option( $user_id, $this->expires_meta_key, $expiration_timestamp );
}
}
$user_identifier = Encryption::hash( $site_identifier_hash );
if ( is_wp_error( $user_identifier ) ) {
return $user_identifier;
}
update_user_option( $user_id, $this->site_hash_meta_key, $site_identifier_hash, true );
update_user_option( $user_id, $this->user_identifier_meta_key, $user_identifier, true );
update_user_option( $user_id, $this->created_by_meta_key, get_current_user_id() );
// Make extra sure that the identifier was saved. Otherwise, things won't work!
return get_user_option( $this->user_identifier_meta_key, $user_id );
}
/**
* Updates the scheduled cron job to auto-revoke and updates the Support User's meta.
*
* @param int $user_id ID of generated support user.
* @param string $site_identifier_hash The unique identifier for the WP_User created {@see Encryption::get_random_hash()}
* @param int $expiration_timestamp Timestamp when user will be removed. Throws error if null/empty.
* @param Cron|null $cron Optional. The Cron object for handling scheduling. Defaults to null.
*
* @return string|WP_Error Value of $identifier_meta_key if worked; empty string or WP_Error if not.
*/
public function extend( $user_id, $site_identifier_hash, $expiration_timestamp = null, $cron = null ) {
if ( ! $user_id || ! $site_identifier_hash || ! $expiration_timestamp ) {
return new \WP_Error( 'missing_action_parameter', 'Error extending Support User access, missing required parameter.' );
}
if ( ! $cron instanceof Cron ) {
// Avoid a Fatal error if `$cron` parameter is not provided.
$cron = new Cron( $this->config, $this->logging );
}
$rescheduled = $cron->reschedule( $expiration_timestamp, $site_identifier_hash );
if ( $rescheduled ) {
update_user_option( $user_id, $this->expires_meta_key, $expiration_timestamp );
return true;
}
return new \WP_Error( 'extend_failed', 'Error rescheduling cron task' );
}
/**
* @param \WP_User|int $user_id_or_object User ID or User object
*
* @return string|WP_Error User unique identifier if success; WP_Error if $user is not int or WP_User.
*/
public function get_user_identifier( $user_id_or_object ) {
if ( empty( $this->user_identifier_meta_key ) ) {
$this->logging->log( 'The meta key to identify users is not set.', __METHOD__, 'error' );
return new \WP_Error( 'missing_meta_key', 'The SupportUser object has not been properly instantiated.' );
}
if ( $user_id_or_object instanceof \WP_User ) {
$user_id = $user_id_or_object->ID;
} elseif ( is_int( $user_id_or_object ) ) {
$user_id = $user_id_or_object;
} else {
$this->logging->log( 'The $user_id_or_object value must be int or WP_User: ' . var_export( $user_id_or_object, true ), __METHOD__, 'error' );
return new \WP_Error( 'invalid_type', '$user must be int or WP_User' );
}
return get_user_option( $this->user_identifier_meta_key, $user_id );
}
/**
* @param WP_User|int $user_id_or_object User ID or User object
*
* @return string|WP_Error User unique identifier if success; WP_Error if $user is not int or WP_User.
*/
public function get_site_hash( $user_id_or_object ) {
if ( empty( $this->site_hash_meta_key ) ) {
$this->logging->log( 'The constructor has not been properly instantiated; the site_hash_meta_key property is not set.', __METHOD__, 'error' );
return new \WP_Error( 'missing_meta_key', 'The SupportUser object has not been properly instantiated.' );
}
if ( $user_id_or_object instanceof \WP_User ) {
$user_id = $user_id_or_object->ID;
} elseif ( is_int( $user_id_or_object ) ) {
$user_id = $user_id_or_object;
} else {
$this->logging->log( 'The $user_id_or_object value must be int or WP_User: ' . var_export( $user_id_or_object, true ), __METHOD__, 'error' );
return new \WP_Error( 'invalid_type', '$user must be int or WP_User' );
}
return get_user_option( $this->site_hash_meta_key, $user_id );
}
/**
* Returns admin URL to revoke support user
*
* @uses SupportUser::get_user_identifier()
*
* @since 1.1 Removed second parameter $current_url.
*
* @param \WP_User|int|string $user User object, user ID, or "all". If "all", will revoke all users.
*
* @return string|false Unsanitized nonce URL to revoke support user. If not able to retrieve user identifier, returns false.
*/
public function get_revoke_url( $user ) {
// If "all", will revoke all support users.
if ( 'all' === $user ) {
$user_identifier = 'all';
} else {
$user_identifier = $this->get_user_identifier( $user );
}
if ( ! $user_identifier || is_wp_error( $user_identifier ) ) {
return false;
}
$revoke_url = add_query_arg( array(
Endpoint::REVOKE_SUPPORT_QUERY_PARAM => $this->config->ns(),
self::ID_QUERY_PARAM => $user_identifier,
'_wpnonce' => wp_create_nonce( Endpoint::REVOKE_SUPPORT_QUERY_PARAM ),
), admin_url() );
$this->logging->log( "revoke_url: $revoke_url", __METHOD__, 'debug' );
return $revoke_url;
}
}