PluginProbe
DoLogin Security / trunk
DoLogin Security vtrunk
5.0.10 4.8.3 trunk 1.0 1.1 1.1.1 1.2 1.2.1 1.2.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 1.6 All 64 releases
dologin / src / auth.cls.php

auth.cls.php in DoLogin Security trunk, at src/auth.cls.php

585 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Login Auth class
5 *
6 * @since 1.0
7 */
8
9 namespace dologin;
10
11 defined( 'WPINC' ) || exit;
12
13 class Auth extends Instance {
14
15 const TYPE_CLEAR_LOG = 'clear_log';
16
17 private $_tb;
18 private $__data;
19 private $_application_password_user_id;
20
21 protected function __construct() {
22 $this->__data = $this->cls( 'Data' );
23 $this->_tb = $this->__data->tb( 'failure' );
24 }
25
26 /**
27 * Init
28 *
29 * @since 1.0
30 * @access public
31 */
32 public function init() {
33 add_action( 'login_head', array( $this, 'login_head' ) );
34 add_filter( 'authenticate', array( $this, 'authenticate' ), 2, 3 );
35 add_filter( 'authenticate', array( $this, 'enforce_klsso' ), PHP_INT_MAX, 3 );
36 add_action( 'application_password_did_authenticate', array( $this, 'allow_application_password' ), 10, 1 );
37 // Cloudflare Turnstile validation.
38 add_filter( 'registration_errors', array( $this, 'registration_errors' ) );
39 add_filter( 'lostpassword_errors', array( $this, 'lostpassword_errors' ) );
40 add_action( 'login_form_lostpassword', array( $this, 'redirect_password_reset' ), 0 );
41 add_action( 'login_form_retrievepassword', array( $this, 'redirect_password_reset' ), 0 );
42 add_action( 'login_form_resetpass', array( $this, 'redirect_password_reset' ), 0 );
43 add_action( 'login_form_rp', array( $this, 'redirect_password_reset' ), 0 );
44 add_filter( 'allow_password_reset', array( $this, 'password_reset_allowed' ), PHP_INT_MAX, 2 );
45 add_action( 'validate_password_reset', array( $this, 'validate_password_reset' ), PHP_INT_MAX, 2 );
46 add_filter( 'lostpassword_url', array( $this, 'lostpassword_url' ), PHP_INT_MAX, 2 );
47 add_action( 'password_reset', array( $this, 'block_password_reset' ), -PHP_INT_MAX, 2 );
48
49 if ( Conf::val( '2fa' ) && ! KLSso::force_enabled() ) {
50 add_filter( 'authenticate', array( $this->cls( 'TwoFA' ), 'authenticate' ), 30, 3 ); // Need to be after WP auth check
51 }
52
53 add_action( 'wp_login_failed', array( $this, 'wp_login_failed' ) );
54
55 // XMLRPC
56 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
57 add_action( 'init', array( $this, 'check_xmlrpc' ) );
58 }
59
60 // Add notices for XMLRPC request
61 add_filter( 'xmlrpc_login_error', array( $this, 'xmlrpc_error_msg' ) );
62 }
63
64 /**
65 * Check Cloudflare Turnstile for registration
66 *
67 * @since 1.9
68 * @access public
69 */
70 public function registration_errors( $errors ) {
71 if ( Conf::val( 'cf' ) && Conf::val( 'recapt_register' ) ) {
72 try {
73 $this->cls( 'Captcha' )->authenticate(); // Need to be before WP auth check
74 } catch ( \Exception $ex ) {
75 $err_code = $ex->getMessage();
76 defined( 'debug' ) && debug( '❌ Turnstile error: ' . $err_code );
77
78 $errors->add( 'captcha_err', Lang::msg( $err_code ) );
79 }
80 }
81
82 return $errors;
83 }
84
85 /**
86 * Check Cloudflare Turnstile for lost-password requests
87 *
88 * @since 1.9
89 * @access public
90 */
91 public function lostpassword_errors( $errors ) {
92 if ( Conf::val( 'cf' ) && Conf::val( 'recapt_forget' ) ) {
93 try {
94 $this->cls( 'Captcha' )->authenticate(); // Need to be before WP auth check
95 } catch ( \Exception $ex ) {
96 $err_code = $ex->getMessage();
97 defined( 'debug' ) && debug( '❌ Turnstile error: ' . $err_code );
98
99 $errors->add( 'captcha_err', Lang::msg( $err_code ) );
100 }
101 }
102
103 return $errors;
104 }
105
106 /**
107 * Redirect public password-reset screens while forced SSO is active.
108 *
109 * @since 4.8.1
110 */
111 public function redirect_password_reset() {
112 if ( ! KLSso::force_enabled() ) {
113 return;
114 }
115
116 wp_safe_redirect( wp_login_url() );
117 exit;
118 }
119
120 /**
121 * Prevent password-reset keys from being created while forced SSO is active.
122 *
123 * @since 4.8.1
124 */
125 public function password_reset_allowed( $allow, $user_id ) {
126 if ( KLSso::force_enabled() ) {
127 return false;
128 }
129
130 return $allow;
131 }
132
133 /**
134 * Prevent existing reset keys from changing passwords while forced SSO is active.
135 *
136 * @since 4.8.1
137 */
138 public function validate_password_reset( $errors, $user ) {
139 if ( KLSso::force_enabled() ) {
140 $errors->add( 'kl_sso_required', __( 'KeyLockr SSO login is required.', 'dologin' ) );
141 }
142 }
143
144 /**
145 * Redirect password-reset links from core and third-party integrations while forced SSO is active.
146 *
147 * @since 4.8.1
148 */
149 public function lostpassword_url( $url, $redirect ) {
150 if ( ! KLSso::force_enabled() || did_action( 'login_init' ) ) {
151 return $url;
152 }
153
154 return wp_login_url( $redirect );
155 }
156
157 /**
158 * Stop direct password resets before WordPress writes the new password.
159 *
160 * @since 4.8.1
161 */
162 public function block_password_reset( $user, $new_pass ) {
163 if ( KLSso::force_enabled() ) {
164 wp_die(
165 __( 'KeyLockr SSO login is required.', 'dologin' ),
166 __( 'Password Reset Disabled', 'dologin' ),
167 array(
168 'response' => 403,
169 'back_link' => true,
170 )
171 );
172 }
173 }
174
175 /**
176 * Login page display messages
177 *
178 * @since 1.0
179 * @access public
180 */
181 public function login_head() {
182 global $error;
183
184 if ( defined( 'DOLOGIN_ERR' ) ) {
185 return;
186 }
187
188 // check whitelist
189 if ( ! $this->try_whitelist() ) {
190 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- appending to the WP login $error global by design.
191 $error .= Lang::msg( 'not_in_whitelist' );
192 return;
193 }
194
195 // check blacklist
196 if ( $this->try_blacklist() ) {
197 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- appending to the WP login $error global by design.
198 $error .= Lang::msg( 'in_blacklist' );
199 return;
200 }
201
202 // Check if has login error
203 $err_msg = $this->_has_login_err( true );
204 if ( $err_msg ) {
205 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- appending to the WP login $error global by design.
206 $error .= $err_msg;
207 return;
208 }
209 }
210
211 /**
212 * Check if has login error limit
213 *
214 * @since 1.0
215 * @access private
216 */
217 private function _has_login_err( $msg_only = false, $duration_rate = false, $retry_rate = false ) {
218 global $wpdb;
219
220 $ip = IP::me();
221 if ( Conf::val( 'gdpr' ) ) {
222 $ip = md5( $ip );
223 }
224
225 $duration = intval( Conf::val( 'duration' ) ) * 60;
226 if ( $duration_rate ) {
227 $duration *= $duration_rate;
228 }
229
230 $q = "SELECT COUNT(*) FROM `$this->_tb` WHERE ip = %s AND dateline > %s";
231 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
232 $err_count = $wpdb->get_var( $wpdb->prepare( $q, array( $ip, time() - $duration ) ) );
233
234 if ( ! $err_count ) {
235 return false;
236 }
237
238 $max_retries = Conf::val( 'max_retries' );
239 if ( $retry_rate ) {
240 $max_retries *= $retry_rate;
241 }
242
243 // Block visit
244 if ( $err_count < $max_retries ) {
245 if ( $msg_only ) {
246 return Lang::msg( 'max_retries', $max_retries - $err_count );
247 }
248 return false;
249 }
250
251 // Can try but has failure
252 return Lang::msg( 'max_retries_hit' );
253 }
254
255 /**
256 * Public check: is the current visitor IP currently over the failure limit?
257 * Reused by tokenized login endpoints (passwordless / site easy-login) that bypass the wp-login flow.
258 *
259 * @since 4.4
260 * @access public
261 */
262 public function is_rate_limited() {
263 return (bool) $this->_has_login_err();
264 }
265
266 /**
267 * Public check for tokenized/passwordless login flows that bypass wp-login authenticate filters.
268 *
269 * @since 4.5
270 * @access public
271 */
272 public function is_ip_denied() {
273 return ! $this->try_whitelist() || $this->try_blacklist();
274 }
275
276 /**
277 * Authenticate
278 *
279 * @since 1.0
280 * @access public
281 */
282 public function authenticate( $user, $username, $password ) {
283 if ( empty( $username ) || empty( $password ) ) {
284 defined( 'debug' ) && debug( 'lack_of_u/p' );
285 return $user;
286 }
287
288 if ( is_wp_error( $user ) ) {
289 defined( 'debug' ) && debug( 'error already' );
290 return $user;
291 }
292
293 $error = new \WP_Error();
294
295 if ( ! $this->try_whitelist() ) {
296 defined( 'debug' ) && debug( '❌ not_in_whitelist' );
297 $error->add( 'not_in_whitelist', Lang::msg( 'not_in_whitelist' ) );
298 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
299 }
300
301 if ( $this->try_blacklist() ) {
302 defined( 'debug' ) && debug( '❌ in_blacklist' );
303 $error->add( 'in_blacklist', Lang::msg( 'in_blacklist' ) );
304 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
305 }
306
307 if ( ! defined( 'DOLOGIN_ERR' ) ) {
308 $err_msg = $this->_has_login_err();
309 if ( $err_msg ) {
310 defined( 'debug' ) && debug( '❌ _has_login_err' );
311 $error->add( 'in_blacklist', $err_msg );
312 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
313 }
314 }
315
316 // Validate Turnstile. Skip XML-RPC: machine clients cannot solve a challenge, and XML-RPC is already covered by the IP limiter + white/blacklist via check_xmlrpc().
317 if ( ! defined( 'DOLOGIN_ERR' ) && Conf::val( 'cf' ) && ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
318 try {
319 $this->cls( 'Captcha' )->authenticate(); // Need to be before WP auth check
320 } catch ( \Exception $ex ) {
321 $err_code = $ex->getMessage();
322 defined( 'debug' ) && debug( '❌ Turnstile error: ' . $err_code );
323
324 $error->add( 'captcha_err', Lang::msg( $err_code ) );
325 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
326 }
327 }
328
329 if ( defined( 'DOLOGIN_ERR' ) ) {
330 // bypass verifying user info
331 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
332 remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
333 return $error;
334 }
335
336 defined( 'debug' ) && debug( '�
337 passed' );
338
339 return $user;
340 }
341
342 /**
343 * Enforce QR-only login after other authentication providers run.
344 *
345 * @since 4.6.5
346 */
347 public function enforce_klsso( $user, $username, $password ) {
348 if ( ! KLSso::force_enabled() ) {
349 return $user;
350 }
351
352 if ( '' === $username && '' === $password ) {
353 // Preserve WordPress core's passive login-page result so a page view is not recorded as a failed login.
354 if ( is_wp_error( $user ) ) {
355 return $user;
356 }
357
358 // Preserve an existing authenticated session without allowing credential-free authentication providers.
359 if (
360 $user instanceof \WP_User
361 && 0 < (int) $user->ID
362 && (int) $user->ID === (int) get_current_user_id()
363 ) {
364 return $user;
365 }
366 }
367
368 if ( $user instanceof \WP_User && (int) $user->ID === (int) $this->_application_password_user_id ) {
369 return $user;
370 }
371
372 $error = new \WP_Error();
373 $error->add( 'kl_sso_required', __( 'KeyLockr SSO login is required.', 'dologin' ) );
374 return $error;
375 }
376
377 /**
378 * Record the user authenticated by a WordPress Application Password for this request.
379 *
380 * @since 4.6.5
381 */
382 public function allow_application_password( $user ) {
383 if ( $user instanceof \WP_User ) {
384 $this->_application_password_user_id = (int) $user->ID;
385 }
386 }
387
388 /**
389 * Block XMLRPC if bad
390 *
391 * @since 1.2
392 * @access public
393 */
394 public function check_xmlrpc() {
395 if ( is_user_logged_in() ) {
396 return;
397 }
398
399 if ( ! $this->try_whitelist() || $this->try_blacklist() || $this->_has_login_err() ) {
400 header( 'HTTP/1.0 403 Forbidden' );
401 exit;
402 }
403 }
404
405 /**
406 * Valiadte XMLRPC
407 *
408 * @since 1.2
409 * @access public
410 */
411 public function xmlrpc_error_msg( $err ) {
412 if ( ! class_exists( 'IXR_Error' ) ) {
413 return $err;
414 }
415
416 if ( ! $this->try_whitelist() ) {
417 return new \IXR_Error( 403, Lang::msg( 'not_in_whitelist' ) );
418 }
419
420 if ( $this->try_blacklist() ) {
421 return new \IXR_Error( 403, Lang::msg( 'in_blacklist' ) );
422 }
423
424 $err_msg = $this->_has_login_err();
425 if ( $err_msg ) {
426 return new \IXR_Error( 403, $err_msg );
427 }
428
429 return $err;
430 }
431
432 /**
433 * Log login failure
434 *
435 * @since 1.0
436 * @access public
437 */
438 public function wp_login_failed( $user ) {
439 global $wpdb;
440
441 $ip = IP::me();
442
443 // Do not trigger external GeoIP requests after the record limit is reached.
444 if ( $this->_has_login_err( false, 10 ) ) {
445 return;
446 }
447
448 // Parse Geo info
449 $ip_geo_list = IP::geo( $ip );
450 unset( $ip_geo_list['ip'] );
451 $ip_geo = array();
452 foreach ( $ip_geo_list as $k => $v ) {
453 $ip_geo[] = $k . ':' . $v;
454 }
455 $ip_geo = implode( ', ', $ip_geo );
456
457 // GDPR compliance
458 if ( Conf::val( 'gdpr' ) ) {
459 $ip = md5( $ip );
460 }
461
462 // Parse gateway
463 $gateway = 'WP Login';
464 // phpcs:ignore WordPress.Security.NonceVerification.Missing
465 if ( isset( $_POST['woocommerce-login-nonce'] ) ) {
466 $gateway = 'WooCommerce';
467 } elseif ( isset( $GLOBALS['wp_xmlrpc_server'] ) && is_object( $GLOBALS['wp_xmlrpc_server'] ) ) {
468 $gateway = 'XMLRPC';
469 }
470
471 $q = "INSERT INTO `$this->_tb` SET ip = %s, ip_geo = %s, username = %s, gateway = %s, dateline = %s";
472 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
473 $wpdb->query( $wpdb->prepare( $q, array( $ip, $ip_geo, $user, $gateway, time() ) ) );
474 }
475
476 /**
477 * Display log
478 *
479 * @since 2.7
480 * @access public
481 */
482 public function history_list( $limit, $offset = false ) {
483 global $wpdb;
484
485 if ( $offset === false ) {
486 $total = $this->count_list();
487 $offset = Util::pagination( $total, $limit, true );
488 }
489
490 $q = "SELECT * FROM `$this->_tb` ORDER BY id DESC LIMIT %d, %d";
491 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
492 return $wpdb->get_results( $wpdb->prepare( $q, $offset, $limit ) );
493 }
494
495 /**
496 * Count the log list
497 */
498 public function count_list() {
499 global $wpdb;
500
501 if ( ! $this->__data->tb_exist( 'failure' ) ) {
502 return false;
503 }
504
505 $q = "SELECT COUNT(*) FROM `$this->_tb`";
506 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; no user input.
507 return $wpdb->get_var( $q );
508 }
509
510 /**
511 * Delete old log
512 */
513 public function _clear_log() {
514 global $wpdb;
515
516 $q = "DELETE FROM `$this->_tb` WHERE dateline < %d";
517 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; value is prepared.
518 $count = $wpdb->query( $wpdb->prepare( $q, time() - 86400 * 30 ) );
519
520 GUI::succeed(
521 sprintf(
522 /* translators: %d: number of cleared records. */
523 __( 'Cleared %d record(s) successfully!', 'dologin' ),
524 $count
525 )
526 );
527 }
528
529 /**
530 * Validate if hit whitelist
531 *
532 * @since 1.0
533 * @access public
534 */
535 private function try_whitelist() {
536 $list = Conf::val( 'whitelist' );
537 if ( ! $list ) {
538 return true;
539 }
540
541 if ( $this->cls( 'IP' )->maybe_hit_rule( $list ) ) {
542 return 'hit';
543 }
544
545 return false;
546 }
547
548 /**
549 * Validate if hit blacklist
550 *
551 * @since 1.0
552 * @access public
553 */
554 private function try_blacklist() {
555 $list = Conf::val( 'blacklist' );
556 if ( ! $list ) {
557 return false;
558 }
559
560 if ( $this->cls( 'IP' )->maybe_hit_rule( $list ) ) {
561 return 'hit';
562 }
563
564 return false;
565 }
566
567 /**
568 * Handler
569 *
570 * @since 2.7
571 */
572 public function handler() {
573 $type = Router::verify_type();
574
575 switch ( $type ) {
576 case self::TYPE_CLEAR_LOG:
577 $this->_clear_log();
578 break;
579
580 default:
581 break;
582 }
583 }
584 }
585