-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpressable-basic-authentication.php
More file actions
498 lines (436 loc) · 17.6 KB
/
Copy pathpressable-basic-authentication.php
File metadata and controls
498 lines (436 loc) · 17.6 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
<?php
/**
* Hosting Basic Authentication
*
* @package HostingBasicAuthentication
*/
/*
Plugin Name: Hosting Basic Authentication
Description: Forces all users to authenticate using Basic Authentication before accessing any page.
Version: 1.0.5
License: GPL2
Text Domain: hosting-basic-authentication
*/
// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
exit; // Prevent direct access
}
/**
* Main plugin class
*/
class Pressable_Basic_Auth {
/**
* Constructor
*/
public function __construct() {
// Hook into WordPress before anything is outputted.
add_action( 'plugins_loaded', array( $this, 'init' ), 1 );
// Logout is handled on `init`, deliberately later than the rest of `init()`.
// wp_logout() fires the `wp_logout` action, and its subscribers may rely on
// constants their own plugin defines in a `plugins_loaded` callback. Firing it
// from `plugins_loaded` priority 1 races that setup, and which plugin wins the
// race depends on load order -- `active_plugins` ordering, anything filtering
// it, and network-activated plugins, which load earlier still. User Switching
// defines its cookie constants that way, so wherever this plugin happens to run
// first, User Switching's `wp_logout` subscriber fatals on a constant it has
// not defined yet. Hooking to `init` drops the dependency on load order
// entirely: every `plugins_loaded` callback has completed by then.
//
// The cost of running this late is that output may already have been sent:
// anything echoed while plugins load -- a `_doing_it_wrong()` notice under
// WP_DEBUG display, a stray BOM -- makes the 401 header and the cookie
// clearing below fail, leaving a 200 with no challenge. wp_logout() itself
// still runs, so the session is destroyed server-side; what is lost is the
// browser-visible half. `plugins_loaded` at PHP_INT_MAX was measured as an
// alternative and degrades identically, because the output is emitted during
// that same hook. The two requirements are in tension: running after every
// `plugins_loaded` callback necessarily means running after any of them may
// have printed, and priority 1 -- the only position that avoids output -- is
// the position that causes the race above.
add_action( 'init', array( $this, 'handle_logout_request' ), 1 );
// Add filter for logout URL.
add_filter( 'logout_url', array( $this, 'modify_logout_url' ), 10, 2 );
// Hook into login page early
add_action( 'login_init', array( $this, 'maybe_redirect_from_login_page' ), 0 );
}
/**
* Initialize the plugin
*/
public function init() {
if ( $this->skip_request() ) {
return;
}
// Redirect from wp-login.php when already authenticated via Basic Auth
$this->maybe_redirect_from_login_page();
// Force authentication.
$this->force_basic_authentication();
}
/**
* Handles the Basic Auth logout request.
*
* Hooked to `init` rather than running with the rest of init() on
* `plugins_loaded` -- see the hook registration in the constructor for why.
*/
public function handle_logout_request() {
if ( $this->skip_request() ) {
return;
}
if ( ! isset( $_GET['basic-auth-logout'] ) ) {
return;
}
$this->handle_basic_auth_logout();
}
/**
* Whether this request is outside the scope of Basic Authentication.
*
* @return bool
*/
private function skip_request() {
return $this->is_ajax_request()
|| $this->is_cron_request()
|| $this->is_cli_request()
|| $this->should_skip_auth();
}
/**
* Force Basic Authentication
*/
private function force_basic_authentication() {
// Prevent caching of authentication requests.
$this->prevent_caching();
// Extract credentials from headers.
$this->extract_basic_auth_credentials();
// Allow Super Admins to bypass authentication.
if ( is_multisite() && is_super_admin() ) {
return;
}
// Check if the user is already logged in.
if ( is_user_logged_in() ) {
return;
}
// Check for Basic Authentication credentials.
$auth_user = isset( $_SERVER['PHP_AUTH_USER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) ) : null;
$auth_pass = isset( $_SERVER['PHP_AUTH_PW'] ) ? $_SERVER['PHP_AUTH_PW'] : null;
if ( ! $auth_user || ! $auth_pass ) {
$this->log_failed_auth( 'Missing credentials' );
$this->send_auth_headers();
}
// Validate credentials against WordPress users table.
$user = wp_authenticate( $auth_user, $auth_pass );
if ( is_wp_error( $user ) ) {
$this->log_failed_auth( "Invalid credentials for user: $auth_user" );
$this->send_auth_headers();
}
// A request asking to log out must still clear the authentication gate above --
// that is what keeps an anonymous caller from reaching wp_logout() -- but it must
// not be given a session that handle_logout_request() discards moments later on
// `init`. Establishing one fires set_auth_cookie and set_logged_in_cookie on what
// is only ever a logout, which an audit or session-tracking plugin can reasonably
// record as a real login.
//
// Skipping wp_set_current_user() as well as the cookies is correct, not a
// shortcut: execution only reaches here when nobody is logged in -- a live session
// returns above -- so there is no established identity for the following
// wp_logout() to report. It passes whatever get_current_user_id() actually holds,
// which is 0, instead of one this method manufactured moments earlier purely to
// tear it down again.
//
// Placement is load-bearing in both directions. Above the credential handling this
// would skip the 401 as well, readmitting the unauthenticated caller it exists to
// exclude; below the cookie calls it would do nothing at all.
if ( isset( $_GET['basic-auth-logout'] ) ) {
return;
}
// Log the user in programmatically.
wp_set_current_user( $user->ID );
wp_set_auth_cookie( $user->ID );
}
/**
* Logs failed authentication attempts to the error log.
*
* @param string $message The message to log.
*/
private function log_failed_auth( $message ) {
error_log(
sprintf(
'[%s] Basic Auth Failed: %s',
gmdate( 'Y-m-d H:i:s' ),
$message
)
);
}
/**
* Check if the current request should skip authentication
*
* @return bool
*/
private function should_skip_auth() {
// REST rewrite targets only. xmlrpc.php is deliberately NOT in this list --
// it is matched on SCRIPT_NAME below, which is authoritative in a way a
// requested path is not.
$excluded_endpoints = array(
'wp-json/jetpack',
'wp-json/wp/v2',
'wp-json/wp/v3'
);
// Get current request details
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
$script_name = $_SERVER['SCRIPT_NAME'] ?? '';
// SCRIPT_NAME is the script the server actually resolved, so this holds
// however the caller spelled the request.
if (basename($script_name) === 'xmlrpc.php') {
return true;
}
// Everything below matches the REQUESTED path, which is not necessarily
// what the server serves. Three spellings made a gated page look like an
// excluded endpoint, each waiving authentication entirely and allowing a
// full WordPress sign-in with no credentials:
//
// /?x=wp-json/wp/v2 query string read as part of the path
// /xmlrpc.php/../wp-login.php `..` resolved by the server afterwards
// /wp-login.php/wp-json/wp/v2/ trailing segments land in PATH_INFO
//
// The guards below are written against the general fault rather than those
// three shapes: the endpoints above are rewrite targets, so they only mean
// anything when the server routes the request to index.php. Decoded first,
// because the server decodes before it resolves.
// The query and fragment are cut by hand rather than with parse_url(), which
// reads a target beginning `//` as a protocol-relative URL and discards the
// first segment as an authority. `//wp-login.php/wp-json/wp/v2/` parsed to
// `/wp-json/wp/v2/` -- the script vanished, leaving nothing before the
// endpoint to object to -- while the server preserved the target, executed
// wp-login.php and passed the rest as PATH_INFO. That served the login form
// and allowed a full WordPress sign-in with no Basic Auth at all. Collapsing
// the leading slashes afterwards is what makes the resulting path comparable.
$cut = strcspn($request_uri, '?#');
$request_path = rawurldecode('/' . ltrim(substr($request_uri, 0, $cut), '/'));
$haystack = rtrim($request_path, '/') . '/';
// A `.` or `..` segment means the path resolves to something other than what
// it reads as, so nothing in it can be trusted to name a destination.
$has_traversal = false;
foreach (explode('/', $haystack) as $segment) {
if ('.' === $segment || '..' === $segment) {
$has_traversal = true;
break;
}
}
// Check all excluded endpoints. Anchored on a slash at both ends so a needle
// matches whole path segments -- `/notwp-json/wp/v2` must not satisfy
// `wp-json/wp/v2` -- but not anchored at the start of the path, because a
// subdirectory or multisite subsite install serves these below a prefix.
if (!$has_traversal) {
foreach ($excluded_endpoints as $endpoint) {
$position = strpos($haystack, '/' . trim($endpoint, '/') . '/');
if (false !== $position && !$this->path_runs_another_script(substr($haystack, 0, $position))) {
return true;
}
}
}
// Check WordPress constants. xmlrpc.php and the REST bootstrap define these
// themselves, so they are evidence from the request's own execution rather
// than from how it was spelled.
if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST) {
return true;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return true;
}
return false;
}
/**
* Whether a path prefix names a script the server would execute.
*
* Only what sits BEFORE an excluded endpoint is asked about. A `.php` segment
* there means the server runs that script and hands the endpoint to it as
* PATH_INFO, so the endpoint is decoration on a gated page:
* `/wp-login.php/wp-json/wp/v2/` served the login form and allowed a full
* WordPress sign-in with no Basic Auth at all.
*
* A `.php` segment AFTER the endpoint is part of the REST route itself --
* `/wp-json/wp/v2/custom-route.php` is routed to index.php and dispatched to
* the REST API -- so an earlier version of this check, which scanned the whole
* path, wrongly demanded authentication for a valid REST request.
*
* Known limitation, accepted deliberately: a WordPress install inside a
* DIRECTORY named `*.php` has a prefix that reads like a script but is not one,
* so REST under it is challenged rather than excluded. Separating the two needs
* either the absence of PATH_INFO as evidence -- trusting a variable's absence,
* which turns this fail-closed edge case into a fail-open one wherever the SAPI
* does not populate it -- or a filesystem lookup that a subdirectory install
* defeats anyway. Refusing a REST request under a pathologically named directory
* is the cheaper error of the two.
*
* @param string $prefix The portion of the request path preceding the endpoint.
* @return bool
*/
private function path_runs_another_script($prefix) {
foreach (explode('/', $prefix) as $segment) {
if ('.php' === strtolower(substr($segment, -4))) {
return true;
}
}
return false;
}
/**
* Sends authentication headers.
*/
private function send_auth_headers() {
header( 'WWW-Authenticate: Basic realm="Restricted Area"' );
header( 'HTTP/1.1 401 Unauthorized' );
echo '<h1>' . esc_html__( 'Authentication Required', 'pressable-basic-auth' ) . '</h1>';
exit;
}
/**
* Use getallheaders() for Servers That Strip Authorization Headers
*/
private function extract_basic_auth_credentials() {
if ( ! empty( $_SERVER['PHP_AUTH_USER'] ) && ! empty( $_SERVER['PHP_AUTH_PW'] ) ) {
return;
}
// Attempt to fetch credentials from Authorization header.
$auth_header = $this->get_authorization_header();
if ( ! $auth_header ) {
return;
}
if ( 0 === stripos( $auth_header, 'basic ' ) ) {
$auth_encoded = substr( $auth_header, 6 );
$auth_decoded = base64_decode( $auth_encoded );
if ( $auth_decoded && strpos( $auth_decoded, ':' ) !== false ) {
list( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) = explode( ':', $auth_decoded, 2 );
}
}
}
/**
* Get the authorization header
*
* @return string|null The authorization header value or null
*/
private function get_authorization_header() {
if ( function_exists( 'getallheaders' ) ) {
$headers = getallheaders();
// Check for Authorization header (case-insensitive).
foreach ( $headers as $key => $value ) {
if ( strtolower( $key ) === 'authorization' ) {
return $value;
}
}
}
// Try common alternative locations.
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
return wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] );
} elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
return wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
}
return null;
}
/**
* Handles Basic Auth logout by forcing a 401 response and then redirecting.
*/
private function handle_basic_auth_logout() {
wp_logout(); // Log out from WordPress.
// Clear Basic Auth credentials by forcing a 401.
header( 'WWW-Authenticate: Basic realm="Restricted Area"' );
header( 'HTTP/1.1 401 Unauthorized' );
// Output a JavaScript-based redirect after the 401 response.
echo '<script>
setTimeout(function() {
window.location.href = "' . esc_url( home_url() ) . '";
}, 1000);
</script>';
// End execution to prevent further processing.
exit;
}
/**
* Modifies the default WordPress logout URL to trigger Basic Auth logout.
*
* @param string $logout_url The WordPress logout URL.
* @param string $redirect The redirect URL after logout.
* @return string Modified logout URL
*/
public function modify_logout_url( $logout_url, $redirect ) {
return add_query_arg( 'basic-auth-logout', '1', $logout_url );
}
/**
* Redirects from wp-login.php to home page when user is already authenticated via Basic Auth
*/
public function maybe_redirect_from_login_page() {
global $pagenow;
// A request that asks to log out is never redirected away from the logout. This
// guard only matters on wp-login.php, and only for a logout URL that omits
// `action=logout` -- the URL modify_logout_url() builds always carries it. Since
// the logout moved to `init`, this method now runs first, and without the guard
// such a request would redirect to the home page still logged in, with no error.
if ( isset( $_GET['basic-auth-logout'] ) ) {
return;
}
// Check if we're on the login page and have Basic Auth credentials
if ( 'wp-login.php' === $pagenow &&
! empty( $_SERVER['PHP_AUTH_USER'] ) &&
! empty( $_SERVER['PHP_AUTH_PW'] ) &&
! isset( $_GET['action'] ) &&
! isset( $_GET['loggedout'] ) &&
! isset( $_POST['log'] ) ) {
// Get appropriate home URL for either multisite or regular WordPress
if ( is_multisite() ) {
$redirect_url = network_home_url();
// If we can determine the current blog, go to its home instead
if ( isset( $_SERVER['HTTP_HOST'] ) ) {
$blog_details = get_blog_details( array( 'domain' => $_SERVER['HTTP_HOST'] ) );
if ( $blog_details ) {
$redirect_url = get_home_url( $blog_details->blog_id );
}
}
} else {
$redirect_url = home_url();
}
// Safe redirect
wp_safe_redirect( $redirect_url );
exit;
}
}
/**
* Prevent caching of authentication requests
*/
private function prevent_caching() {
header( 'Cache-Control: no-cache, must-revalidate, max-age=0' );
header( 'Pragma: no-cache' );
header( 'Expires: Wed, 11 Jan 1984 05:00:00 GMT' );
}
/**
* Check if the current request is an AJAX request
*
* Matched only on the `DOING_AJAX` constant, which WordPress defines itself
* when `admin-ajax.php` runs -- evidence from the request's own execution that
* a caller cannot forge. The `X-Requested-With: XMLHttpRequest` request header
* was deliberately removed: it is set by the caller, so keying an auth waiver
* on it let any anonymous request turn Basic Auth off on any URL,
* `wp-login.php` included, by sending one header -- the same full bypass the
* `should_skip_auth()` rewrite closes for path spellings, reached with a
* header instead. It covered nothing the constant does not: real WordPress
* AJAX runs through admin-ajax.php with `DOING_AJAX` set, and the REST API is
* handled separately by `should_skip_auth()`. A custom endpoint that needs
* access sends Basic Auth like anything else.
*
* @return bool
*/
private function is_ajax_request() {
return defined( 'DOING_AJAX' ) && DOING_AJAX;
}
/**
* Check if the current request is a cron request
*
* @return bool
*/
private function is_cron_request() {
return defined( 'DOING_CRON' ) && DOING_CRON;
}
/**
* Check if the current request is a CLI request
*
* @return bool
*/
private function is_cli_request() {
return ( 'cli' === php_sapi_name() || ( defined( 'WP_CLI' ) && WP_CLI ) );
}
}
// Initialize the plugin.
new Pressable_Basic_Auth();