' . esc_html__( 'Too many attempts.', 'ai-eshop-optimizer' ) . ' ' . esc_html__( 'Sign-in from this address is paused for another %s. Nothing has happened to the account, and it unlocks on its own.', 'ai-eshop-optimizer' ), human_time_diff( time(), $until ) ) ); } public static function locked_until( $ip ) { global $wpdb; if ( '' === (string) $ip ) { return 0; } $table = aieo_login_attempts_table(); $row = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(`locked_until`) FROM `{$table}` WHERE `ip` = %s AND `locked_until` > UTC_TIMESTAMP()", $ip ) ); return $row ? (int) strtotime( $row . ' UTC' ) : 0; } public static function record_failure( $username, $error = null ) { if ( ! AIEO_Login_Security::feature_active( 'lockout' ) ) { return; } if ( $error instanceof WP_Error && $error->get_error_code() === 'aieo_locked_out' ) { return; } global $wpdb; $table = aieo_login_attempts_table(); $ip = AIEO_Login_Security::client_ip(); if ( '' === $ip ) { return; } $user = get_user_by( 'login', $username ); if ( ! $user ) { $user = get_user_by( 'email', $username ); } $user_id = $user ? (int) $user->ID : 0; $wpdb->insert( $table, array( 'ip' => $ip, 'user_id' => $user_id, 'outcome' => 'failed', 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%d', '%s', '%s' ) ); $conf = AIEO_Login_Security::feature( 'lockout' ); $max = max( 1, (int) $conf['max_attempts'] ); $window = max( 1, (int) $conf['window_min'] ); $failures = self::recent_failures( $ip, $window ); if ( $failures < $max ) { return; } $priors = self::recent_locks( $ip ); $long = ( (int) $conf['long_lock_after'] > 0 && $priors + 1 >= (int) $conf['long_lock_after'] ); $seconds = $long ? max( 1, (int) $conf['long_lock_hours'] ) * HOUR_IN_SECONDS : max( 1, (int) $conf['lock_min'] ) * MINUTE_IN_SECONDS; $wpdb->insert( $table, array( 'ip' => $ip, 'user_id' => $user_id, 'outcome' => 'locked', 'locked_until' => gmdate( 'Y-m-d H:i:s', time() + $seconds ), 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%d', '%s', '%s', '%s' ) ); if ( ! empty( $conf['notify'] ) && $user_id && AIEO_Login_Security::user_is_protected( $user_id ) ) { self::notify( $user_id, $ip, $seconds ); } } private static function recent_failures( $ip, $window_minutes ) { global $wpdb; $table = aieo_login_attempts_table(); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE `ip` = %s AND `outcome` = 'failed' AND `created_at` >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d MINUTE) AND `created_at` > COALESCE(( SELECT MAX(`created_at`) FROM `{$table}` WHERE `ip` = %s AND `outcome` = 'locked' ), '1970-01-01 00:00:00')", $ip, (int) $window_minutes, $ip ) ); } private static function recent_locks( $ip ) { global $wpdb; $table = aieo_login_attempts_table(); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE `ip` = %s AND `outcome` = 'locked' AND `created_at` >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)", $ip ) ); } public static function clear_on_success( $login, $user = null ) { if ( ! AIEO_Login_Security::feature_active( 'lockout' ) ) { return; } global $wpdb; $table = aieo_login_attempts_table(); $ip = AIEO_Login_Security::client_ip(); if ( '' === $ip ) { return; } $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET `outcome` = 'cleared' WHERE `ip` = %s AND `outcome` = 'failed'", $ip ) ); } public static function prune() { global $wpdb; $table = aieo_login_attempts_table(); $wpdb->query( "DELETE FROM `{$table}` WHERE `created_at` < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)" ); } private static function notify( $user_id, $ip, $seconds ) { $user = get_userdata( (int) $user_id ); if ( ! $user || ! is_email( $user->user_email ) ) { return; } $flag = 'aieo_lockout_notified_' . (int) $user_id; if ( get_transient( $flag ) ) { return; } set_transient( $flag, 1, HOUR_IN_SECONDS ); $subject = sprintf( __( '[%s] Somebody is trying to sign in as you', 'ai-eshop-optimizer' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ); $body = sprintf( __( "There have been repeated failed sign-in attempts for your account \"%1\$s\".\n\n" . "The attempts came from %2\$s and have now been blocked for %3\$s.\n\n" . "You do not need to do anything: your account has not been changed and your password still works. " . "If this was you, wait for the block to expire and try again, or use your recovery code.\n\n" . "If it was not you, and you use this password anywhere else, change it there.", 'ai-eshop-optimizer' ), $user->user_login, $ip, human_time_diff( time(), time() + (int) $seconds ) ); wp_mail( $user->user_email, $subject, $body ); } public static function recent( $limit = 50 ) { global $wpdb; $table = aieo_login_attempts_table(); return (array) $wpdb->get_results( $wpdb->prepare( "SELECT `ip`, `user_id`, `outcome`, `locked_until`, `created_at` FROM `{$table}` ORDER BY `id` DESC LIMIT %d", max( 1, (int) $limit ) ) ); } public static function summary() { global $wpdb; $table = aieo_login_attempts_table(); $row = $wpdb->get_row( "SELECT SUM(CASE WHEN `outcome` IN ('failed','cleared') AND `created_at` >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY) THEN 1 ELSE 0 END) AS failed, SUM(CASE WHEN `outcome` = 'locked' AND `created_at` >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY) THEN 1 ELSE 0 END) AS locked, COUNT(DISTINCT CASE WHEN `locked_until` > UTC_TIMESTAMP() THEN `ip` END) AS active FROM `{$table}`" ); return array( 'failed' => $row ? (int) $row->failed : 0, 'locked' => $row ? (int) $row->locked : 0, 'active' => $row ? (int) $row->active : 0, ); } public static function release_all() { global $wpdb; $table = aieo_login_attempts_table(); return (int) $wpdb->query( "UPDATE `{$table}` SET `locked_until` = NULL, `outcome` = 'released' WHERE `locked_until` > UTC_TIMESTAMP()" ); } }