Endpoint.php
11 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
<?php
/**
* Class Endpoint
*
* @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;
use \Exception;
use \WP_Error;
use \WP_User;
use \WP_Admin_Bar;
class Endpoint {
/**
* @var string The query string parameter used to revoke users
*/
const REVOKE_SUPPORT_QUERY_PARAM = 'revoke-tl';
/**
* @var string Site option used to track whether permalinks have been flushed.
*/
const PERMALINK_FLUSH_OPTION_NAME = 'tl_permalinks_flushed';
/**
* @var string Expected value of $_POST['action'] before adding the endpoint and starting a login flow.
*/
const POST_ACTION_VALUE = 'trustedlogin';
/** @var string The $_POST key in the TrustedLogin request related to the action being performed. */
const POST_ACTION_KEY = 'action';
/** @var string The $_POST key in the TrustedLogin request that contains the value of the expected endpoint. */
const POST_ENDPOINT_KEY = 'endpoint';
/** @var string The $_POST key in the TrustedLogin request related to the action being performed. */
const POST_IDENTIFIER_KEY = 'identifier';
/**
* @var Config $config
*/
private $config;
/**
* The namespaced setting name for storing part of the auto-login endpoint
*
* @var string $option_name Example: `tl_{vendor/namespace}_endpoint`
*/
private $option_name;
/**
* @var SupportUser
* @todo decouple
*/
private $support_user;
/**
* @var Logging $logging
*/
private $logging;
/**
* Logger constructor.
*/
public function __construct( Config $config, Logging $logging ) {
$this->config = $config;
$this->logging = $logging;
$this->support_user = new SupportUser( $config, $logging );
/**
* Filter: Set endpoint setting name
*
* @since 1.0.0
*
* @param string $option_name
* @param Config $config
*/
$this->option_name = apply_filters(
'trustedlogin/' . $config->ns() . '/options/endpoint',
'tl_' . $config->ns() . '_endpoint',
$config
);
}
public function init() {
if ( did_action( 'init' ) ) {
$this->add();
} else {
add_action( 'init', array( $this, 'add' ) );
}
add_action( 'template_redirect', array( $this, 'maybe_login_support' ), 99 );
add_action( 'init', array( $this, 'maybe_revoke_support' ), 100 );
add_action( 'admin_init', array( $this, 'maybe_revoke_support' ), 100 );
}
/**
* Check if the endpoint is hit and has a valid identifier before automatically logging in support agent.
*
* @since 1.0.0
*
* @return void
*/
public function maybe_login_support() {
// The user's already logged-in; don't override that login.
if ( is_user_logged_in() ) {
return;
}
$request = $this->get_trustedlogin_request();
// Not a TrustedLogin request.
if ( ! $request ) {
return;
}
$endpoint = $this->get();
// The expected endpoint doesn't match the one in the request.
if ( $endpoint !== $request[ self::POST_ENDPOINT_KEY ] ) {
return;
}
// The sanitized, unhashed identifier for the support user.
$user_identifier = $request[ self::POST_IDENTIFIER_KEY ];
if ( empty( $user_identifier ) ) {
return;
}
/**
* Runs before the support user is (maybe) logged-in, but after the endpoint is verified.
*
* @param string $user_identifier Unique identifier for support user, sanitized using {@see sanitize_text_field}.
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/login/before', $user_identifier );
$security_checks = new SecurityChecks( $this->config, $this->logging );
// Before logging-in support, let's make sure the site isn't locked-down or that this request is flagged
$is_verified = $security_checks->verify( $user_identifier );
if ( ! $is_verified || is_wp_error( $is_verified ) ) {
/**
* Runs after the identifier fails security checks.
*
* @param string $user_identifier Unique identifier for support user.
* @param WP_Error $is_verified The error encountered when verifying the identifier.
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/login/refused', $user_identifier, $is_verified );
return;
}
$is_logged_in = $this->support_user->maybe_login( $user_identifier );
if ( is_wp_error( $is_logged_in ) ) {
/**
* Runs after the support user fails to log in
*
* @param string $user_identifier Unique Identifier for support user.
* @param WP_Error $is_logged_in The error encountered when logging-in.
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/login/error', $user_identifier, $is_logged_in );
return;
}
/**
* Runs after the support user is logged-in.
*
* @param string $user_identifier Unique Identifier for support user.
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/login/after', $user_identifier );
wp_safe_redirect( admin_url() );
exit();
}
/**
* Hooked Action to maybe revoke support if $_REQUEST[ SupportUser::ID_QUERY_PARAM ] == {namespace}
* Can optionally check for $_REQUEST[ SupportUser::ID_QUERY_PARAM ] for revoking a specific user by their identifier
*
* @since 1.0.0
*/
public function maybe_revoke_support() {
if ( ! isset( $_REQUEST[ self::REVOKE_SUPPORT_QUERY_PARAM ] ) ) {
return;
}
if ( $this->config->ns() !== $_REQUEST[ self::REVOKE_SUPPORT_QUERY_PARAM ] ) {
return;
}
if ( ! isset( $_REQUEST['_wpnonce'] ) ) {
return;
}
$verify_nonce = wp_verify_nonce( $_REQUEST['_wpnonce'], self::REVOKE_SUPPORT_QUERY_PARAM );
if ( ! $verify_nonce ) {
$this->logging->log( 'Removing user failed: Nonce expired (Nonce value: ' . $verify_nonce . ')', __METHOD__, 'error' );
return;
}
// Allow namespaced support team to revoke their own users
$support_team = current_user_can( $this->support_user->role->get_name() );
// As well as existing users who can delete other users
$can_delete_users = current_user_can( 'delete_users' );
if ( ! $support_team && ! $can_delete_users ) {
wp_safe_redirect( home_url() );
return;
}
$user_identifier = isset( $_REQUEST[ SupportUser::ID_QUERY_PARAM ] ) ? esc_attr( $_REQUEST[ SupportUser::ID_QUERY_PARAM ] ) : 'all';
/**
* Trigger action to revoke access based on Support User identifier.
*
* @used-by Cron::revoke
*
* @param string $user_identifier Unique ID for TrustedLogin support user or "all".
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/access/revoke', $user_identifier );
$should_be_deleted = $this->support_user->get( $user_identifier );
if ( ! empty( $should_be_deleted ) ) {
$this->logging->log( 'User #' . $should_be_deleted->ID . ' was not removed', __METHOD__, 'error' );
return; // Don't trigger `access_revoked` if anything fails.
}
/**
* Only triggered when all access has been successfully revoked and no users exist with identifier $identifer.
*
* @param string $user_identifier Unique TrustedLogin ID for the Support User or "all"
*/
do_action( 'trustedlogin/' . $this->config->ns() . '/admin/access_revoked', $user_identifier );
}
/**
* Hooked Action: Add a unique endpoint to WP if a support agent exists
*
* @since 1.0.0
* @see Endpoint::init() Called via `init` hook
*
*/
public function add() {
// Only add the endpoint if a TrustedLogin request is being made.
if ( ! $this->get_trustedlogin_request() ) {
return;
}
$endpoint = $this->get();
if ( ! $endpoint ) {
return;
}
add_rewrite_endpoint( $endpoint, EP_ROOT );
$this->logging->log( "Endpoint {$endpoint} added.", __METHOD__, 'debug' );
if ( get_site_option( self::PERMALINK_FLUSH_OPTION_NAME ) ) {
return;
}
flush_rewrite_rules( false );
$this->logging->log( 'Rewrite rules flushed.', __METHOD__, 'info' );
$updated_option = update_site_option( self::PERMALINK_FLUSH_OPTION_NAME, 1 );
if ( false === $updated_option ) {
$this->logging->log( 'Permalink flush option was not properly set.', 'warning' );
}
}
/**
* Get the site option value at {@see option_name}
*
* @return string
*/
public function get() {
return (string) get_site_option( $this->option_name );
}
/**
* Returns sanitized data from a TrustedLogin login $_POST request.
*
* Note: This is not a security check. It is only used to determine whether the request contains the expected keys.
*
* @since 1.1
*
* @return false|array{action:string, endpoint:string, identifier: string} If false, the request is not from TrustedLogin. If the request is from TrustedLogin, an array with the posted keys, santiized.
*/
private function get_trustedlogin_request() {
if ( ! isset( $_POST[ self::POST_ACTION_KEY ], $_POST[ self::POST_ENDPOINT_KEY ], $_POST[ self::POST_IDENTIFIER_KEY ] ) ) {
return false;
}
if ( self::POST_ACTION_VALUE !== $_POST[ self::POST_ACTION_KEY ] ) {
return false;
}
$_sanitized_post_data = array_map( 'sanitize_text_field', $_POST );
// Return only the expected keys.
return array(
self::POST_ACTION_KEY => $_sanitized_post_data[ self::POST_ACTION_KEY ],
self::POST_ENDPOINT_KEY => $_sanitized_post_data[ self::POST_ENDPOINT_KEY ],
self::POST_IDENTIFIER_KEY => $_sanitized_post_data[ self::POST_IDENTIFIER_KEY ],
);
}
/**
* Generate the secret_id parameter as a hash of the endpoint with the identifier
*
* @param string $site_identifier_hash
* @param string $endpoint_hash
*
* @return string|WP_Error This hash will be used as an identifier in TrustedLogin SaaS. Or something went wrong.
*/
public function generate_secret_id( $site_identifier_hash, $endpoint_hash = '' ) {
if ( empty( $endpoint_hash ) ) {
$endpoint_hash = $this->get_hash( $site_identifier_hash );
}
if ( is_wp_error( $endpoint_hash ) ) {
return $endpoint_hash;
}
return Encryption::hash( $endpoint_hash . $site_identifier_hash );
}
/**
* Generate the endpoint parameter as a hash of the site URL with the identifier
*
* @param $site_identifier_hash
*
* @return string This hash will be used as the first part of the URL and also a part of $secret_id
*/
public function get_hash( $site_identifier_hash ) {
return Encryption::hash( get_site_url() . $site_identifier_hash );
}
/**
* Updates the site's endpoint to listen for logins. Flushes rewrite rules after updating.
*
* @param string $endpoint
*
* @return bool True: updated; False: didn't change, or didn't update
*/
public function update( $endpoint ) {
$updated = update_site_option( $this->option_name, $endpoint );
update_site_option( self::PERMALINK_FLUSH_OPTION_NAME, 0 );
return $updated;
}
/**
*
* @return void
*/
public function delete() {
if ( ! get_site_option( $this->option_name ) ) {
$this->logging->log( 'Endpoint not deleted because it does not exist.', __METHOD__, 'info' );
return;
}
delete_site_option( $this->option_name );
flush_rewrite_rules( false );
update_site_option( self::PERMALINK_FLUSH_OPTION_NAME, 0 );
$this->logging->log( 'Endpoint removed & rewrites flushed', __METHOD__, 'info' );
}
}