PluginProbe
Advanced Access Manager – Access Governance for WordPress / 6.9.26
Advanced Access Manager – Access Governance for WordPress v6.9.26
7.1.4 7.1.2 7.1.3 6.8.4 6.8.5 6.9.0 6.9.1 6.9.10 6.9.11 6.9.12 6.9.13 6.9.14 6.9.15 6.9.16 6.9.17 6.9.18 6.9.19 6.9.2 6.9.20 6.9.21 6.9.22 6.9.23 6.9.24 6.9.25 6.9.26 All 210 releases
advanced-access-manager / application / Service / SecureLogin.php
SecureLogin.php
701 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * ======================================================================
5 * LICENSE: This file is subject to the terms and conditions defined in *
6 * file 'license.txt', which is part of this source code package. *
7 * ======================================================================
8 */
9
10 use Vectorface\Whip\Whip;
11
12 /**
13 * Secure Login service
14 *
15 * @since 6.9.19 https://github.com/aamplugin/advanced-access-manager/issues/332
16 * @since 6.9.17 https://github.com/aamplugin/advanced-access-manager/issues/319
17 * @since 6.9.12 https://github.com/aamplugin/advanced-access-manager/issues/284
18 * https://github.com/aamplugin/advanced-access-manager/issues/244
19 * @since 6.9.11 https://github.com/aamplugin/advanced-access-manager/issues/278
20 * @since 6.9.10 https://github.com/aamplugin/advanced-access-manager/issues/276
21 * @since 6.6.2 https://github.com/aamplugin/advanced-access-manager/issues/139
22 * @since 6.6.1 https://github.com/aamplugin/advanced-access-manager/issues/136
23 * @since 6.4.2 https://github.com/aamplugin/advanced-access-manager/issues/91
24 * @since 6.4.0 https://github.com/aamplugin/advanced-access-manager/issues/16
25 * https://github.com/aamplugin/advanced-access-manager/issues/71
26 * @since 6.3.1 Fixed bug with not being able to lock user
27 * @since 6.1.0 Enriched error response with more details
28 * @since 6.0.0 Initial implementation of the class
29 *
30 * @package AAM
31 * @version 6.9.19
32 */
33 class AAM_Service_SecureLogin
34 {
35 use AAM_Core_Contract_RequestTrait,
36 AAM_Core_Contract_ServiceTrait;
37
38 /**
39 * Service alias
40 *
41 * Is used to get service instance if it is enabled
42 *
43 * @version 6.4.0
44 */
45 const SERVICE_ALIAS = 'secure-login';
46
47 /**
48 * AAM configuration setting that is associated with the service
49 *
50 * @version 6.0.0
51 */
52 const FEATURE_FLAG = 'core.service.secure-login.enabled';
53
54 /**
55 * Config options aliases
56 *
57 * The option names changed, but to stay backward compatible, we need to support
58 * legacy names.
59 *
60 * @version 6.9.11
61 */
62 const OPTION_ALIAS = array(
63 'service.secure_login.time_window' => 'service.secureLogin.settings.attemptWindow',
64 'service.secure_login.login_attempts' => 'service.secureLogin.settings.loginAttempts'
65 );
66
67 /**
68 * Constructor
69 *
70 * @return void
71 *
72 * @access protected
73 * @version 6.0.0
74 */
75 protected function __construct()
76 {
77 if (is_admin()) {
78 // Hook that returns the detailed information about the nature of the
79 // service. This is used to display information about service on the
80 // Settings->Services tab
81 add_filter('aam_service_list_filter', function ($services) {
82 $services[] = array(
83 'title' => __('Secure Login', AAM_KEY),
84 'description' => __('Enhance default WordPress authentication process with more secure login mechanism. The service registers frontend AJAX Login widget as well as additional endpoints for the RESTful API authentication.', AAM_KEY),
85 'setting' => self::FEATURE_FLAG
86 );
87
88 return $services;
89 }, 1);
90
91 // Register additional tab for the Settings
92 if (AAM_Core_Config::get(self::FEATURE_FLAG, true)) {
93 add_action('aam_init_ui_action', function () {
94 AAM_Backend_Feature_Settings_Security::register();
95 }, 1);
96 }
97 }
98
99 if (AAM_Core_Config::get(self::FEATURE_FLAG, true)) {
100 $this->initializeHooks();
101 }
102 }
103
104 /**
105 * Initialize core hooks
106 *
107 * @return void
108 *
109 * @since 6.9.10 https://github.com/aamplugin/advanced-access-manager/issues/276
110 * @since 6.4.0 https://github.com/aamplugin/advanced-access-manager/issues/71
111 * @since 6.0.0 Initial implementation of the method
112 *
113 * @access protected
114 * @version 6.9.10
115 */
116 protected function initializeHooks()
117 {
118 // Register custom frontend Login widget
119 add_action('widgets_init', function () {
120 register_widget('AAM_Backend_Widget_Login');
121 });
122
123 // Register custom RESTful API endpoint for login
124 add_action('rest_api_init', array($this, 'registerRESTfulRoute'));
125
126 // User login control
127 add_filter('wp_authenticate_user', array($this, 'validateUserStatus'), 1, 2);
128 add_filter('aam_verify_user_filter', array($this, 'validateUserStatus'));
129
130 // Redefine the wp-login.php header message
131 add_filter('login_message', array($this, 'loginMessage'));
132
133 // Security controls
134 add_filter('authenticate', array($this, 'enhanceAuthentication'), PHP_INT_MAX);
135 add_filter('auth_cookie', array($this, 'manageAuthCookie'), 10, 5);
136 add_action('wp_login_failed', array($this, 'trackFailedLoginAttempt'));
137
138 // AAM UI controls
139 add_filter('aam_user_row_actions_filter', function($actions, $user) {
140 // Move this to the Secure Login Service
141 if (current_user_can('aam_toggle_users')) {
142 $status = get_user_meta($user->ID, 'aam_user_status', true);
143 $actions[] = ($status === 'locked' ? 'unlock' : 'lock');
144 }
145
146 return $actions;
147 }, 10, 2);
148 add_filter('aam_ajax_filter', array($this, 'handleAjax'), 10, 3);
149 add_filter('aam_user_expiration_actions_filter', function($actions) {
150 $actions['lock'] = __('Block User Account', AAM_KEY);
151
152 return $actions;
153 });
154 add_action('aam_process_inactive_user_action', array($this, 'lockUser'), 10, 2);
155
156 // AAM Core integration
157 add_action('aam_initialize_user_action', function(AAM_Core_Subject_User $user) {
158 $currentId = get_current_user_id();
159
160 if ($currentId === $user->ID) {
161 $status = get_user_meta($user->ID, 'aam_user_status', true);
162
163 if ($status === 'locked') {
164 wp_logout();
165 }
166 }
167 });
168
169 // Service fetch
170 $this->registerService();
171 }
172
173 /**
174 * Register RESTful Route
175 *
176 * Register AAM authentication endpoint
177 *
178 * @since 6.6.1 https://github.com/aamplugin/advanced-access-manager/issues/136
179 * @since 6.4.2 Enhanced https://github.com/aamplugin/advanced-access-manager/issues/91
180 * @since 6.0.0 Initial implementation of the method
181 *
182 * @return void
183 * @version 6.6.1
184 */
185 public function registerRESTfulRoute()
186 {
187 $config = array(
188 'methods' => 'POST',
189 'callback' => array($this, 'authenticate'),
190 'permission_callback' => '__return_true',
191 'args' => apply_filters('aam_restful_authentication_args_filter', array(
192 'username' => array(
193 'description' => 'Valid username.',
194 'type' => 'string',
195 ),
196 'password' => array(
197 'description' => 'Valid password.',
198 'type' => 'string',
199 ),
200 'redirect' => array(
201 'description' => 'Redirect URL after authentication.',
202 'type' => 'string',
203 ),
204 'remember' => array(
205 'description' => 'Prolong the user session.',
206 'type' => 'boolean',
207 ),
208 'returnAuthCookies' => array(
209 'description' => 'Return auth cookies.',
210 'type' => 'boolean',
211 )
212 )),
213 );
214
215 register_rest_route('aam/v2', '/authenticate', $config);
216
217 // For backward compatibility, keep /v1/authenticate endpoint
218 register_rest_route('aam/v1', '/authenticate', array(
219 'methods' => 'POST',
220 'callback' => array($this, 'legacyAuthenticate'),
221 'permission_callback' => '__return_true',
222 'args' => array(
223 'username' => array(
224 'description' => __('Valid username.', AAM_KEY),
225 'type' => 'string',
226 ),
227 'password' => array(
228 'description' => __('Valid password.', AAM_KEY),
229 'type' => 'string',
230 )
231 ),
232 ));
233 }
234
235 /**
236 * Authenticate user
237 *
238 * @param WP_REST_Request $request
239 *
240 * @return WP_REST_Response
241 *
242 * @since 6.9.19 https://github.com/aamplugin/advanced-access-manager/issues/332
243 * @since 6.6.2 https://github.com/aamplugin/advanced-access-manager/issues/139
244 * @since 6.4.2 https://github.com/aamplugin/advanced-access-manager/issues/91
245 * @since 6.4.0 https://github.com/aamplugin/advanced-access-manager/issues/16
246 * @since 6.1.0 Enriched error response with more details
247 * @since 6.0.0 Initial implementation of the method
248 *
249 * @access public
250 * @version 6.9.19
251 */
252 public function authenticate(WP_REST_Request $request)
253 {
254 $status = 200;
255
256 // No need to generate Auth cookies, unless explicitly stated so
257 if ($request->get_param('returnAuthCookies') !== true) {
258 add_filter('send_auth_cookies', '__return_false');
259 }
260
261 $user = wp_signon(array(
262 'user_login' => $request->get_param('username'),
263 'user_password' => $request->get_param('password'),
264 'remember' => $request->get_param('remember')
265 ));
266
267 try {
268 if (!is_wp_error($user)) {
269 $redirect = $request->get_param('redirect');
270 $result = apply_filters('aam_auth_response_filter', array(
271 'user' => $this->prepareUserData($user),
272 'redirect' => $redirect ? wp_validate_redirect($redirect) : null
273 ), $request, $user);
274 } else {
275 $status = 403;
276 $result = array(
277 'code' => $user->get_error_code(),
278 'reason' => $user->get_error_message()
279 );
280 }
281 } catch(Exception $e) {
282 $status = $e->getCode();
283 $result = array(
284 'reason' => $e->getMessage()
285 );
286 }
287
288 return new WP_REST_Response($result, $status);
289 }
290
291 /**
292 * Authenticate user
293 *
294 * @param WP_REST_Request $request
295 *
296 * @return WP_REST_Response
297 *
298 * @since 6.6.2 https://github.com/aamplugin/advanced-access-manager/issues/139
299 * @since 6.4.2 Initial implementation of the method
300 *
301 * @access public
302 * @version 6.6.2
303 */
304 public function legacyAuthenticate(WP_REST_Request $request)
305 {
306 _deprecated_function('aam/v1/authenticate', '6.4.2', 'aam/v2/authenticate');
307
308 $user = wp_signon(array(
309 'user_login' => $request->get_param('username'),
310 'user_password' => $request->get_param('password')
311 ));
312
313 if (is_a($user, 'WP_User')) {
314 $status = 200;
315
316 // Making sure that token is issued
317 $request->set_param('issueJWT', true);
318
319 $result = apply_filters(
320 'aam_auth_response_filter',
321 array('user' => $this->prepareUserData($user)),
322 $request,
323 $user
324 );
325 } else {
326 $status = 403;
327 $result = new WP_Error(
328 'rest_jwt_auth_failure',
329 strip_tags($user->get_error_message())
330 );
331 }
332
333 return new WP_REST_Response($result, $status);
334 }
335
336 /**
337 * Prepare user data that is returned
338 *
339 * @param WP_User $user
340 *
341 * @return array
342 *
343 * @access protected
344 * @version 6.6.2
345 */
346 protected function prepareUserData($user) {
347 $response = array('data' => array());
348
349 $props = array(
350 'ID', 'user_login', 'user_nicename', 'display_name', 'user_url',
351 'user_email', 'user_registered'
352 );
353
354 foreach($props as $prop) {
355 $response['data'][$prop] = $user->{$prop};
356 }
357
358 return $response;
359 }
360
361 /**
362 * Intercept auth token generation and enhance security
363 *
364 * If "One Session Per User" option is enabled, make sure that all other sessions
365 * are removed
366 *
367 * @param string $cookie Authentication cookie.
368 * @param int $user_id User ID.
369 * @param int $expiration The time the cookie expires as a UNIX timestamp.
370 * @param string $scheme Cookie scheme used. Accepts 'auth', 'secure_auth', or 'logged_in'.
371 * @param string $token User's session token used.
372 *
373 * @return string
374 *
375 * @access public
376 * @version 6.0.0
377 */
378 public function manageAuthCookie($cookie, $user_id, $expiration, $scheme, $token)
379 {
380 // Remove all other sessions if single session feature is enabled
381 if (AAM_Core_Config::get('service.secureLogin.feature.singleSession', false)) {
382 $sessions = WP_Session_Tokens::get_instance($user_id);
383
384 if (count($sessions->get_all()) > 1) {
385 $sessions->destroy_others($token);
386 }
387 }
388
389 return $cookie;
390 }
391
392 /**
393 * Track failed login attempts
394 *
395 * This method is used to enable brute force protection
396 *
397 * @return void
398 *
399 * @access public
400 * @version 6.0.0
401 */
402 public function trackFailedLoginAttempt()
403 {
404 // Track failed attempts only if Brute Force Lockout is enabled
405 if (AAM_Core_Config::get('service.secureLogin.feature.bruteForceLockout', false)) {
406 $this->updateLoginAttemptsTransient(1);
407 }
408 }
409
410 /**
411 * Increment/Decrement failed login attempts transient
412 *
413 * @param int $counter
414 *
415 * @return void
416 *
417 * @since 6.9.17 https://github.com/aamplugin/advanced-access-manager/issues/319
418 * @since 6.0.0 Initial implementation of the method
419 *
420 * @access protected
421 * @version 6.9.17
422 */
423 protected function updateLoginAttemptsTransient($counter)
424 {
425 $name = $this->_getLoginAttemptKeyName();
426 $attempts = AAM_Core_Cache::get($name);
427
428 if ($attempts !== false) {
429 $timeout = get_option("_transient_timeout_{$name}");
430 $attempts = intval($attempts) + $counter;
431 } else {
432 $attempts = 1;
433 $timeout = strtotime(
434 $this->_getConfigOption(
435 'service.secure_login.time_window', '+20 minutes'
436 )
437 );
438 }
439
440 AAM_Core_Cache::set($name, $attempts, $timeout - time());
441 }
442
443 /**
444 * Get login attempts transient name
445 *
446 * @return string
447 *
448 * @since 6.9.17 https://github.com/aamplugin/advanced-access-manager/issues/319
449 * @since 6.9.12 https://github.com/aamplugin/advanced-access-manager/issues/244
450 * @since 6.0.0 Initial implementation of method
451 *
452 * @access private
453 * @version 6.9.17
454 */
455 private function _getLoginAttemptKeyName()
456 {
457 $whip = new Whip();
458
459 return 'failed_login_attempts_' . $whip->getValidIpAddress();
460 }
461
462 /**
463 * Pre-authentication hook
464 *
465 * Enhance authentication security with Brute Force protection or login delay
466 *
467 * @param mixed $response
468 *
469 * @return mixed
470 *
471 * @since 6.9.17 https://github.com/aamplugin/advanced-access-manager/issues/319
472 * @since 6.0.0 Initial implementation of the method
473 *
474 * @access public
475 * @see wp_authenticate
476 * @version 6.9.17
477 */
478 public function enhanceAuthentication($response)
479 {
480 // Brute Force Lockout
481 if (AAM_Core_Config::get('service.secureLogin.feature.bruteForceLockout', false)) {
482 $attempts = AAM_Core_Cache::get($this->_getLoginAttemptKeyName());
483 $threshold = $this->_getConfigOption(
484 'service.secure_login.login_attempts', 8
485 );
486
487 if ($attempts >= $threshold) {
488 $response = new WP_Error(
489 405,
490 __('Exceeded maximum number for authentication attempts. Try again later.', AAM_KEY)
491 );
492 }
493 }
494
495 return $response;
496 }
497
498 /**
499 * Validate user status
500 *
501 * Check if user is locked or not
502 *
503 * @param WP_Error $user
504 *
505 * @return WP_Error|WP_User
506 *
507 * @since 6.9.10 https://github.com/aamplugin/advanced-access-manager/issues/276
508 * @since 6.0.0 Initial implementation of the method
509 *
510 * @access public
511 * @version 6.9.10
512 */
513 public function validateUserStatus($user)
514 {
515 // Check if user is blocked
516 if (is_a($user, 'WP_User')) {
517 $status = get_user_meta($user->ID, 'aam_user_status', true);
518
519 if ($status === 'locked') {
520 $user = new WP_Error(
521 405,
522 AAM_Backend_View_Helper::preparePhrase(
523 '[ERROR]: User is locked. Contact website administrator.',
524 'strong'
525 )
526 );
527 }
528 }
529
530 return $user;
531 }
532
533 /**
534 * Customize login message
535 *
536 * @param string $message
537 *
538 * @return string
539 *
540 * @since 6.9.12 https://github.com/aamplugin/advanced-access-manager/issues/284
541 * @since 6.0.0 Initial implementation of the method
542 *
543 * @access public
544 * @version 6.9.12
545 */
546 public function loginMessage($message)
547 {
548 if (empty($message) && ($this->getFromQuery('reason') === 'restricted')) {
549 $str = $this->_getConfigOption(
550 'service.secure_login.login_message',
551 __('Access is restricted. Login to get access.', AAM_KEY)
552 );
553
554 $message = '<p class="message">' . $str . '</p>';
555 }
556
557 return $message;
558 }
559
560 /**
561 * Handle AAM UI ajax calls
562 *
563 * @param mixed $response
564 * @param AAM_Core_Subject_User $user
565 * @param string $action
566 *
567 * @return mixed
568 *
569 * @since 6.3.1 https://github.com/aamplugin/advanced-access-manager/issues/43
570 * @since 6.0.0 Initial implementation of the method
571 *
572 * @access public
573 * @version 6.3.1
574 */
575 public function handleAjax($response, $user, $action)
576 {
577 if ($action === 'Service_SecureLogin.toggleUserStatus') {
578 $result = $this->toggleUserStatus($user);
579 $response = wp_json_encode(
580 array('status' => ($result ? 'success' : 'failure'))
581 );
582 }
583
584 return $response;
585 }
586
587 /**
588 * Lock user
589 *
590 * This method is invoked when user is expired
591 *
592 * @param array $trigger
593 * @param AAM_Core_Subject_User $user
594 *
595 * @return void
596 *
597 * @since 6.9.10 https://github.com/aamplugin/advanced-access-manager/issues/276
598 * @since 6.0.0 Initial implementation of the method
599 *
600 * @access public
601 * @version 6.9.10
602 */
603 public function lockUser(array $trigger, AAM_Core_Subject_User $user)
604 {
605 if ($trigger['action'] === 'lock') {
606 $this->changeUserStatus($user->getPrincipal(), true);
607 wp_logout();
608 }
609 }
610
611 /**
612 * Toggle user status
613 *
614 * Either block or unblock user record
615 *
616 * @param AAM_Core_Subject_User $user
617 *
618 * @return void
619 *
620 * @since 6.9.10 https://github.com/aamplugin/advanced-access-manager/issues/276
621 * @since 6.0.0 Initial implementation of the method
622 *
623 * @access protected
624 * @version 6.9.10
625 */
626 protected function toggleUserStatus(AAM_Core_Subject_User $user)
627 {
628 $result = false;
629
630 if (current_user_can('aam_toggle_users') && current_user_can('edit_users')) {
631 if (apply_filters('aam_user_can_manage_level_filter', true, $user->getMaxLevel())) {
632 // User is not allowed to lock himself
633 if (intval($user->getId()) !== get_current_user_id()) {
634 $status = get_user_meta($user->ID, 'aam_user_status', true);
635 $result = $this->changeUserStatus(
636 $user->getPrincipal(), $status !== 'locked'
637 );
638 }
639 }
640 }
641
642 return $result;
643 }
644
645 /**
646 * Change user status
647 *
648 * @param WP_User $user
649 * @param bool $lock
650 *
651 * @return boolean
652 *
653 * @since 6.9.10 https://github.com/aamplugin/advanced-access-manager/issues/276
654 * @since 6.0.0 Initial implementation of the method
655 *
656 * @access protected
657 * @version 6.9.10
658 */
659 protected function changeUserStatus(WP_User $user, $lock)
660 {
661 if ($lock) {
662 add_user_meta($user->ID, 'aam_user_status', 'locked');
663 } else {
664 delete_user_meta($user->ID, 'aam_user_status');
665 }
666
667 clean_user_cache($user);
668
669 return true;
670 }
671
672 /**
673 * Get configuration option
674 *
675 * @param string $option
676 * @param mixed $default
677 *
678 * @return mixed
679 *
680 * @since 6.9.12 https://github.com/aamplugin/advanced-access-manager/issues/287
681 * @since 6.9.11 Initial implementation of the method
682 *
683 * @access private
684 * @version 6.9.12
685 */
686 private function _getConfigOption($option, $default = null)
687 {
688 $value = AAM_Core_Config::get($option);
689
690 if (is_null($value) && array_key_exists($option, self::OPTION_ALIAS)) {
691 $value = AAM_Core_Config::get(self::OPTION_ALIAS[$option]);
692 }
693
694 return is_null($value) ? $default : $value;
695 }
696
697 }
698
699 if (defined('AAM_KEY')) {
700 AAM_Service_SecureLogin::bootstrap();
701 }