PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-login-lockout.php

class-login-lockout.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at src/component/class-login-lockout.php

400 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles the logic for locking out users after too many failed login attempts.
4 *
5 * @package WP_Defender\Component
6 */
7
8 namespace WP_Defender\Component;
9
10 use WP_User;
11 use WP_Error;
12 use WP_Defender\Component;
13 use WP_Defender\Traits\Country;
14 use WP_Defender\Model\Lockout_Ip;
15 use WP_Defender\Model\Lockout_Log;
16
17 /**
18 * Handles the logic for locking out users after too many failed login attempts.
19 */
20 class Login_Lockout extends Component {
21
22 use Country;
23
24 public const SCENARIO_LOGIN_FAIL = 'login_fail', SCENARIO_LOGIN_LOCKOUT = 'login_lockout', SCENARIO_BAN = 'login_ban';
25
26 /**
27 * The model containing settings for login lockout.
28 *
29 * @var \WP_Defender\Model\Setting\Login_Lockout
30 */
31 protected $model;
32
33 /**
34 * Blacklist_Lockout Service for handling blacklist checks.
35 *
36 * @var Blacklist_Lockout
37 */
38 protected $service;
39
40 /**
41 * Message displayed when a user is banned.
42 *
43 * @var string
44 */
45 protected $banned_username_message;
46
47 /**
48 * List of IPs associated with the current user.
49 *
50 * @var array
51 */
52 protected $ip;
53
54 /**
55 * Constructor for initializing the Login_Lockout component.
56 */
57 public function __construct() {
58 // Todo: maybe add model and ip-params?
59 $this->model = wd_di()->get( \WP_Defender\Model\Setting\Login_Lockout::class );
60 $this->service = wd_di()->get( Blacklist_Lockout::class );
61 $this->banned_username_message = esc_html__(
62 'You have been locked out by the administrator for attempting to login with a banned username.',
63 'defender-security'
64 );
65 $this->ip = $this->get_user_ip();
66 }
67
68 /**
69 * Adding main hooks.
70 */
71 public function add_hooks() {
72 global $wp_version;
73
74 if ( isset( $wp_version ) && version_compare( $wp_version, '5.4.0', '>=' ) ) {
75 add_action( 'wp_login_failed', array( $this, 'process_fail_attempt' ), 10, 2 );
76 } else {
77 add_action( 'wp_login_failed', array( $this, 'process_fail_attempt_compatibility' ), 10 );
78 }
79
80 add_filter( 'authenticate', array( $this, 'show_attempt_left' ), 9999, 2 );
81 add_action( 'wp_login', array( $this, 'clear_login_attempt' ) );
82 add_action( 'wd_2fa_lockout', array( $this, 'two_factor_lockout' ), 10, 3 );
83 }
84
85 /**
86 * When a user logins successfully, we need to clear the info of failed login attempt.
87 * So it won't affect the next time that user logins again.
88 */
89 public function clear_login_attempt() {
90 // Record this.
91 foreach ( $this->ip as $ip ) {
92 $model = Lockout_Ip::get( $ip );
93 if ( is_object( $model ) ) {
94 $model->meta = array();
95 $model->attempt = 0;
96 $model->save();
97 }
98 }
99 }
100
101 /**
102 * Show a message to tell user how many attempt they have until get lockout.
103 *
104 * @param WP_User|WP_Error|null $user The result of the authentication attempt.
105 * @param string $username The username used to attempt login.
106 *
107 * @return WP_User|WP_Error|null
108 */
109 public function show_attempt_left( $user, $username ) {
110 $request_method = defender_get_data_from_request( 'REQUEST_METHOD', 's' );
111 if ( ! is_wp_error( $user ) && $user instanceof WP_User
112 && in_array( $username, $this->model->get_blacklisted_username(), true )
113 ) {
114 // The case#1 of an existing user who has a banned username.
115 $msg = sprintf(
116 '<strong>%s:</strong> %s',
117 esc_html__( 'Error', 'defender-security' ),
118 $this->banned_username_message
119 );
120 $errors = new WP_Error();
121 $errors->add( 'def_login_banned_user', $msg );
122
123 return $errors;
124 } elseif ( 'POST' === $request_method
125 && is_wp_error( $user )
126 && ! in_array(
127 $user->get_error_code(),
128 array( 'empty_username', 'empty_password' ),
129 true
130 )
131 ) {
132 // The case#2 of a non-existent user who has a banned username.
133 if ( in_array( $username, $this->model->get_blacklisted_username(), true ) ) {
134 $msg = $this->banned_username_message;
135 $user->add( 'def_login_attempt', $msg );
136
137 return $user;
138 }
139 // This hook is before the @process_fail_attempt, so we will need to add 1 into the attempt count.
140 $attempt = $this->get_max_attempt();
141 ++$attempt;
142 if ( $attempt < $this->model->attempt ) {
143 $user->add(
144 'def_login_attempt',
145 sprintf(
146 /* translators: %d: Count of attempts. */
147 esc_html__( '%d login attempts remaining', 'defender-security' ),
148 $this->model->attempt - $attempt
149 )
150 );
151 } else {
152 $user->add( 'def_login_attempt', $this->model->lockout_message );
153 }
154 }
155
156 return $user;
157 }
158
159 /**
160 * Handles failed login attempts for WordPress versions older than 5.4.0.
161 *
162 * @param string $username The username used in the failed login attempt.
163 */
164 public function process_fail_attempt_compatibility( $username ) {
165 if ( ! is_string( $username ) || '' === $username ) {
166 return;
167 }
168
169 if ( in_array( $username, $this->model->get_blacklisted_username(), true ) ) {
170 $msg = sprintf(
171 '<strong>%s:</strong> %s',
172 esc_html__( 'Error', 'defender-security' ),
173 $this->banned_username_message
174 );
175 $errors = new WP_Error( 'def_login_banned_user', $msg );
176 } else {
177 $errors = new WP_Error( 'dummy_failed', esc_html__( 'Dummy data.', 'defender-security' ) );
178 }
179
180 $this->process_fail_attempt( $username, $errors );
181 }
182
183 /**
184 * Checks and updates the metadata for a lockout IP model.
185 *
186 * @param Lockout_Ip $model The lockout IP model to check.
187 *
188 * @return Lockout_Ip The updated model.
189 */
190 protected function check_meta_data( &$model ) {
191 if (
192 ! isset( $model->meta['login'] ) ||
193 ( isset( $model->meta['login'] ) && ! is_array( $model->meta['login'] ) )
194 ) {
195 $model->meta['login'] = array();
196 }
197
198 return $model;
199 }
200
201 /**
202 * Processes a failed login attempt, records it, logs it, and checks if the IP should be locked.
203 *
204 * @param string $username The username used in the failed login attempt.
205 * @param WP_Error $error The error object associated with the login failure.
206 */
207 public function process_fail_attempt( $username, $error ) {
208 if ( ! is_string( $username ) || '' === $username ) {
209 return;
210 }
211
212 foreach ( $this->ip as $ip ) {
213 if ( $this->service->is_ip_whitelisted( $ip ) ) {
214 continue;
215 }
216 // Record this.
217 $model = Lockout_Ip::get( $ip );
218 $model = $this->record_fail_attempt( $ip, $model );
219 // Avoid duplicate logs.
220 if ( 'def_login_banned_user' !== $error->get_error_code() ) {
221 $this->log_event( $ip, $username, self::SCENARIO_LOGIN_FAIL );
222 }
223 // Now check, if it is in a banned username.
224 $ls = $this->model;
225 if ( in_array( $username, $ls->get_blacklisted_username(), true ) ) {
226 $model->lockout_message = $this->banned_username_message;
227 $model->status = Lockout_Ip::STATUS_BLOCKED;
228 $model->save();
229 $this->log_event( $ip, $username, self::SCENARIO_BAN );
230
231 do_action( 'wd_login_lockout', $model, self::SCENARIO_BAN );
232 do_action( 'wd_blacklist_this_ip', $ip );
233
234 continue;
235 }
236 // So if we can lock.
237 $window = strtotime( '-' . $ls->timeframe . 'seconds' );
238
239 $model = $this->check_meta_data( $model );
240 // We will get the latest till oldest, limit by attempt.
241 $checks = array_slice( $model->meta['login'], $ls->attempt * - 1 );
242
243 if ( count( $checks ) < $ls->attempt ) {
244 // Do nothing.
245 continue;
246 }
247 // if the last time is larger.
248 $check = min( $checks );
249 if ( $check >= $window ) {
250 if ( 'permanent' === $ls->lockout_type ) {
251 $model->attempt = 0;
252 $model->meta['login'] = array();
253 $model->save();
254
255 do_action( 'wd_blacklist_this_ip', $ip );
256 } else {
257 // Lockable.
258 $model->status = Lockout_Ip::STATUS_BLOCKED;
259 $model->lock_time = time();
260
261 $this->create_blocked_lockout(
262 $model,
263 $ls->lockout_message,
264 strtotime( '+' . $ls->duration . ' ' . $ls->duration_unit )
265 );
266 }
267 // Need to create a log.
268 $this->log_event( $ip, $username, self::SCENARIO_LOGIN_LOCKOUT );
269 do_action( 'wd_login_lockout', $model, self::SCENARIO_LOGIN_LOCKOUT );
270 }
271 }
272 }
273
274 /**
275 * Creates a lockout for a blocked IP.
276 *
277 * @param Lockout_Ip $model The lockout IP model.
278 * @param string $message The lockout message.
279 * @param int $time The timestamp when the lockout will be lifted.
280 */
281 public function create_blocked_lockout( &$model, $message, $time ) {
282 $model->lockout_message = $message;
283 $model->release_time = $time;
284 $model->save();
285 }
286
287 /**
288 * Handles lockouts triggered by two-factor authentication failures.
289 *
290 * @param int $user_id The user ID.
291 * @param string $message The lockout message.
292 * @param int $time_limit The duration of the lockout in seconds.
293 */
294 public function two_factor_lockout( $user_id, $message, $time_limit ) {
295 // Prepare a record for Lockout_IP.
296 $start_time = time();
297 $user = get_user_by( 'id', $user_id );
298 $def_values = $this->model->get_default_values();
299
300 $ips = array_filter(
301 $this->ip,
302 function ( $ip ) {
303 return ! $this->service->is_ip_whitelisted( $ip );
304 }
305 );
306 $models = Lockout_Ip::get_bulk( '', $ips );
307 foreach ( $models as $model ) {
308 $model->status = Lockout_Ip::STATUS_BLOCKED;
309 $model->lock_time = $start_time;
310 $model = $this->check_meta_data( $model );
311 $model->meta['login'][] = $start_time;
312
313 $this->create_blocked_lockout( $model, $def_values['message'], $start_time + $time_limit );
314 $this->log_event( $model->ip, $user->user_login ?? '', self::SCENARIO_LOGIN_LOCKOUT, $message );
315 // No need to add the current IP to blocklisted.
316 }
317 }
318
319 /**
320 * Records a failed login attempt for an IP.
321 *
322 * @param string $ip The IP address.
323 * @param Lockout_Ip $model The lockout IP model.
324 *
325 * @return Lockout_Ip The updated lockout IP model.
326 */
327 protected function record_fail_attempt( $ip, $model ): Lockout_Ip {
328 $model->attempt += 1;
329 $model->ip = $ip;
330
331 $model = $this->check_meta_data( $model );
332 // Cache the time here, so it consumes less memory than query the logs.
333 $model->meta['login'][] = time();
334 $model->save();
335
336 return $model;
337 }
338
339 /**
340 * Logs an event related to log in attempts.
341 *
342 * @param string $ip The IP address.
343 * @param string $username The username involved in the event.
344 * @param string $scenario The scenario constant (e.g., SCENARIO_LOGIN_FAIL).
345 * @param string $message Additional message for the log.
346 */
347 public function log_event( $ip, $username, $scenario, $message = '' ) {
348 $model = Lockout_Log::create( $ip, $username );
349
350 switch ( $scenario ) {
351 case self::SCENARIO_LOGIN_FAIL:
352 $model->type = Lockout_Log::AUTH_FAIL;
353 $model->log = sprintf(
354 /* translators: %s: Username. */
355 esc_html__( 'Failed login attempt with username %s', 'defender-security' ),
356 $username
357 );
358 break;
359 case self::SCENARIO_BAN:
360 $model->type = Lockout_Log::AUTH_LOCK;
361 $model->log = sprintf(
362 /* translators: %s: Username. */
363 esc_html__( 'Failed login attempt with a ban username %s', 'defender-security' ),
364 $username
365 );
366 break;
367 case self::SCENARIO_LOGIN_LOCKOUT:
368 default:
369 $model->type = Lockout_Log::AUTH_LOCK;
370 $model->log = ( '' !== $message )
371 ? $message
372 : esc_html__( 'Lockout occurred: Too many failed login attempts', 'defender-security' );
373 break;
374 }
375 $model->save();
376 if ( Lockout_Log::AUTH_LOCK === $model->type ) {
377 do_action( 'defender_notify', 'firewall-notification', $model );
378 }
379 }
380
381 /**
382 * Get the max attempt from the list of IPs.
383 *
384 * @return int
385 * @since 4.4.2
386 */
387 public function get_max_attempt(): int {
388 $attempt = 0;
389 $models = Lockout_Ip::get_bulk( '', $this->ip );
390
391 foreach ( $models as $model ) {
392 if ( isset( $model->attempt ) && $attempt < $model->attempt ) {
393 $attempt = $model->attempt;
394 }
395 }
396
397 return $attempt;
398 }
399 }
400