579 lines
23 KiB
PHP
579 lines
23 KiB
PHP
<?php
|
|
|
|
namespace ASENHA\Classes;
|
|
|
|
use WP_Error;
|
|
|
|
/**
|
|
* Class for Limit Login Attempts module
|
|
*
|
|
* @since 6.9.5
|
|
*/
|
|
class Limit_Login_Attempts {
|
|
|
|
/**
|
|
* Maybe allow login if not locked out. Should return WP_Error object if not allowed to login.
|
|
*
|
|
* @since 2.5.0
|
|
*/
|
|
public function maybe_allow_login( $user_or_error, $username, $password ) {
|
|
global $wpdb, $asenha_limit_login;
|
|
|
|
$table_name = $wpdb->prefix . 'asenha_failed_logins';
|
|
|
|
// Maybe create table if it does not exist yet, e.g. upgraded from previous version of plugin, so, no activation methods are fired
|
|
$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) );
|
|
|
|
if ( $wpdb->get_var( $query ) === $table_name ) {
|
|
// Table already exists, do nothing.
|
|
} else {
|
|
$activation = new Activation;
|
|
$activation->create_failed_logins_log_table();
|
|
}
|
|
|
|
// Get values from options needed to do various checks
|
|
$options = get_option( ASENHA_SLUG_U, array() );
|
|
$login_fails_allowed = $options['login_fails_allowed'];
|
|
$login_lockout_maxcount = $options['login_lockout_maxcount'];
|
|
|
|
$ip_address_whitelist_raw = ( isset( $options['limit_login_attempts_ip_whitelist'] ) ) ? explode( PHP_EOL, $options['limit_login_attempts_ip_whitelist'] ) : array();
|
|
$ip_address_whitelist = array();
|
|
if ( ! empty( $ip_address_whitelist_raw ) ) {
|
|
foreach( $ip_address_whitelist_raw as $ip_address ) {
|
|
$ip_address_whitelist[] = trim( $ip_address );
|
|
}
|
|
}
|
|
|
|
$change_login_url = $options['change_login_url'];
|
|
$custom_login_slug = $options['custom_login_slug'];
|
|
|
|
// Instantiate object to access common methods
|
|
$common_methods = new Common_Methods;
|
|
|
|
// Get user/visitor IP address
|
|
$ip_address = $common_methods->get_user_ip_address( 'ip', 'limit-login-attempts' );
|
|
|
|
if ( ! in_array( $ip_address, $ip_address_whitelist ) ) { // IP is not whitelisted
|
|
// Check if IP address has failed login attempts recorded in the DB log
|
|
$sql = $wpdb->prepare("SELECT * FROM `" . $table_name . "` Where `ip_address` = %s", $ip_address);
|
|
$result = $wpdb->get_results( $sql, ARRAY_A );
|
|
|
|
$result_count = count( $result );
|
|
|
|
if ( $result_count > 0 ) { // IP address has been recorded in the database.
|
|
$fail_count = $result[0]['fail_count'];
|
|
$lockout_count = $result[0]['lockout_count'];
|
|
$last_fail_on = $result[0]['unixtime'];
|
|
} else {
|
|
$fail_count = 0;
|
|
$lockout_count = 0;
|
|
$last_fail_on = '';
|
|
}
|
|
} else { // IP is whitelisted
|
|
$result = array();
|
|
$result_count = 0;
|
|
$fail_count = 0;
|
|
$lockout_count = 0;
|
|
$last_fail_on = '';
|
|
}
|
|
|
|
// Initialize the global variable
|
|
$asenha_limit_login = array (
|
|
'ip_address' => $ip_address,
|
|
'request_uri' => sanitize_text_field( $_SERVER['REQUEST_URI'] ),
|
|
'ip_address_log' => $result,
|
|
'fail_count' => $fail_count,
|
|
'lockout_count' => $lockout_count,
|
|
'maybe_lockout' => false,
|
|
'extended_lockout' => false,
|
|
'within_lockout_period' => false,
|
|
'lockout_period' => 0,
|
|
'lockout_period_remaining' => 0,
|
|
'login_fails_allowed' => $login_fails_allowed,
|
|
'login_lockout_maxcount' => $login_lockout_maxcount,
|
|
// 'default_lockout_period' => 15, // 15 seconds. FOR TESTING.
|
|
// 'default_lockout_period' => 60, // 1 minutes in seconds
|
|
'default_lockout_period' => 60*15, // 15 minutes in seconds
|
|
// 'extended_lockout_period' => 3*60, // 3 minutes in seconds
|
|
'extended_lockout_period' => 24*60*60, // 24 hours in seconds
|
|
'change_login_url' => $change_login_url, // is custom login URL enabled?
|
|
'custom_login_slug' => $custom_login_slug,
|
|
);
|
|
|
|
if ( ! in_array( $ip_address, $ip_address_whitelist ) ) { // IP is not whitelisted
|
|
|
|
if ( $result_count > 0 ) { // IP address has been recorded in the database.
|
|
|
|
// Failed attempts have been recorded and fulfills lockout condition
|
|
if ( ! empty( $fail_count ) && ( ( $fail_count ) % $login_fails_allowed == 0 ) ) {
|
|
|
|
$asenha_limit_login['maybe_lockout'] = true;
|
|
|
|
// Has reached max / gone beyond number of lockouts allowed?
|
|
if ( $lockout_count >= $login_lockout_maxcount ) {
|
|
$asenha_limit_login['extended_lockout'] = true;
|
|
$lockout_period = $asenha_limit_login['extended_lockout_period'];
|
|
} else {
|
|
$asenha_limit_login['extended_lockout'] = false;
|
|
$lockout_period = $asenha_limit_login['default_lockout_period'];
|
|
}
|
|
|
|
$asenha_limit_login['lockout_period'] = $lockout_period;
|
|
|
|
// User/visitor is still within the lockout period
|
|
if ( ( time() - $last_fail_on ) <= $asenha_limit_login['lockout_period'] ) {
|
|
|
|
$asenha_limit_login['within_lockout_period'] = true;
|
|
$asenha_limit_login['lockout_period_remaining'] = $asenha_limit_login['lockout_period'] - ( time() - $last_fail_on );
|
|
|
|
if ( $asenha_limit_login['lockout_period_remaining'] <= 60 ) {
|
|
|
|
// Get remaining lockout period in minutes and seconds
|
|
$lockout_period_remaining = $asenha_limit_login['lockout_period_remaining'] . ' seconds';
|
|
|
|
} elseif ( $asenha_limit_login['lockout_period_remaining'] <= 60*60 ) {
|
|
|
|
// Get remaining lockout period in minutes and seconds
|
|
$lockout_period_remaining = $common_methods->seconds_to_period( $asenha_limit_login['lockout_period_remaining'], 'to-minutes-seconds' );
|
|
|
|
} elseif ( $asenha_limit_login['lockout_period_remaining'] > 60*60 && $asenha_limit_login['lockout_period_remaining'] <= 24*60*60 ) {
|
|
|
|
// Get remaining lockout period in minutes and seconds
|
|
$lockout_period_remaining = $common_methods->seconds_to_period( $asenha_limit_login['lockout_period_remaining'], 'to-hours-minutes-seconds' );
|
|
|
|
} elseif ( $asenha_limit_login['lockout_period_remaining'] > 24*60*60 ) {
|
|
|
|
// Get remaining lockout period in minutes and seconds
|
|
$lockout_period_remaining = $common_methods->seconds_to_period( $asenha_limit_login['lockout_period_remaining'], 'to-days-hours-minutes-seconds' );
|
|
|
|
}
|
|
|
|
$error = new WP_Error( 'ip_address_blocked', '<b>WARNING:</b> You\'ve been locked out. You can login again in ' . $lockout_period_remaining . '.' );
|
|
|
|
return $error;
|
|
|
|
} else { // User/visitor is no longer within the lockout period
|
|
|
|
$asenha_limit_login['within_lockout_period'] = false;
|
|
|
|
if ( $lockout_count == $login_lockout_maxcount ) {
|
|
|
|
// Remove the DB log entry for the current IP address. i.e. release from extended lockout
|
|
|
|
$where = array( 'ip_address' => $ip_address );
|
|
$where_format = array( '%s' );
|
|
|
|
// Delete existing data in the database
|
|
$wpdb->delete(
|
|
$table_name,
|
|
$where,
|
|
$where_format
|
|
);
|
|
|
|
}
|
|
|
|
return $user_or_error;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
$asenha_limit_login['maybe_lockout'] = false;
|
|
|
|
return $user_or_error;
|
|
|
|
}
|
|
|
|
} else { // IP address has not been recorded in the database.
|
|
|
|
return $user_or_error;
|
|
|
|
}
|
|
|
|
} else { // IP is whitelisted
|
|
return $user_or_error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle login errors
|
|
*
|
|
* @link https://developer.wordpress.org/reference/classes/wp_error/#methods
|
|
* @since 2.5.0
|
|
*/
|
|
public function login_error_handler( $errors, $redirect_to ) {
|
|
global $asenha_limit_login;
|
|
|
|
if ( is_wp_error( $errors ) ) {
|
|
|
|
$error_codes = $errors->get_error_codes();
|
|
|
|
foreach ( $error_codes as $error_code ) {
|
|
|
|
if ( $error_code == 'invalid_username' || $error_code == 'incorrect_password' ) {
|
|
|
|
// Remove default error messages that may give out valueable info to hackers
|
|
|
|
$errors->remove( 'invalid_username' ); // Outputs info that says username does not exist. May encourage login attempt with a different username instead.
|
|
|
|
$errors->remove( 'incorrect_password' ); // Outputs info that implies username exist. May encourage login attempt with a different password.
|
|
|
|
// Add a new error message that does not provide useful clues to hackers
|
|
$errors->add( 'invalid_username_or_incorrect_password', '<b>' . __( 'Error:', 'admin-site-enhancements' ) . '</b> ' . __( 'Invalid username/email or incorrect password.', 'admin-site-enhancements' ) );
|
|
|
|
// $errors->add( 'another_error_code', 'The error message.' );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* Disable login form inputs via CSS
|
|
*
|
|
* @since 2.5.0
|
|
*/
|
|
public function maybe_hide_login_form() {
|
|
global $asenha_limit_login;
|
|
|
|
if ( isset( $asenha_limit_login['within_lockout_period'] ) && $asenha_limit_login['within_lockout_period'] ) {
|
|
|
|
// Hide logo, login form and the links below it
|
|
?>
|
|
<script>
|
|
document.addEventListener("DOMContentLoaded", function(event) {
|
|
var loginForm = document.getElementById("loginform");
|
|
loginForm.remove();
|
|
});
|
|
</script>
|
|
<style type="text/css">
|
|
|
|
body.login {
|
|
background:#f6d6d7;
|
|
}
|
|
|
|
#login h1,
|
|
#loginform,
|
|
#login #nav,
|
|
#backtoblog,
|
|
.language-switcher {
|
|
display: none;
|
|
}
|
|
|
|
@media screen and (max-height: 550px) {
|
|
|
|
#login {
|
|
padding: 80px 0 20px !important;
|
|
}
|
|
|
|
}
|
|
|
|
</style>
|
|
<?php
|
|
} else {
|
|
$options = get_option( ASENHA_SLUG_U, array() );
|
|
$login_fails_allowed = $options['login_fails_allowed'];
|
|
$page_was_reloaded = isset( $_GET['rl'] ) && 1 == sanitize_text_field( $_GET['rl'] ) ? true : false;
|
|
|
|
if ( isset( $asenha_limit_login['fail_count'] )
|
|
&& ( ( $login_fails_allowed - 1 ) == intval( $asenha_limit_login['fail_count'] )
|
|
|| ( 2 * $login_fails_allowed - 1 ) == intval( $asenha_limit_login['fail_count'] )
|
|
|| ( 3 * $login_fails_allowed - 1 ) == intval( $asenha_limit_login['fail_count'] )
|
|
|| ( 4 * $login_fails_allowed - 1 ) == intval( $asenha_limit_login['fail_count'] )
|
|
|| ( 5 * $login_fails_allowed - 1 ) == intval( $asenha_limit_login['fail_count'] )
|
|
|| ( 6 * $login_fails_allowed - 1 ) == intval( $asenha_limit_login['fail_count'] )
|
|
)
|
|
) {
|
|
if ( array_key_exists( 'change_login_url', $options ) && $options['change_login_url'] ) {
|
|
// Custom Login URL is enabled, e.g. /manage
|
|
// Do nothing
|
|
} else {
|
|
// Default login URL, i.e. /wp-login.php
|
|
// Reload the login page so we get the up-to-date data in $asenha_limit_login
|
|
// Only reload if page was not reloaded before. This prevents infinite reloads.
|
|
if ( ! $page_was_reloaded ) {
|
|
?>
|
|
<script>
|
|
let url = window.location.href;
|
|
if (url.indexOf('?') > -1){
|
|
url += '&rl=1'
|
|
} else {
|
|
url += '?rl=1'
|
|
}
|
|
location.replace(url);
|
|
</script>
|
|
<?php
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add login error message on top of the login form
|
|
*
|
|
* @since 2.5.0
|
|
*/
|
|
public function add_failed_login_message( $message ) {
|
|
global $asenha_limit_login;
|
|
|
|
if ( isset( $_REQUEST['failed_login'] ) && $_REQUEST['failed_login'] == 'true' ) {
|
|
|
|
if ( ! is_null( $asenha_limit_login ) && isset( $asenha_limit_login['within_lockout_period'] ) && ! $asenha_limit_login['within_lockout_period'] ) {
|
|
|
|
$message = '<div id="login_error" class="notice notice-error"><b>' . __( 'Error:', 'admin-site-enhancements' ) . '</b> ' . __( 'Invalid username/email or incorrect password.', 'admin-site-enhancements' ) . '</div>';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return $message;
|
|
}
|
|
|
|
/**
|
|
* Log failed login attempts
|
|
*
|
|
* @since 2.5.0
|
|
*/
|
|
public function log_failed_login( $username ) {
|
|
global $wpdb, $asenha_limit_login;
|
|
|
|
$table_name = $wpdb->prefix . 'asenha_failed_logins';
|
|
|
|
$ip_address = isset( $asenha_limit_login['ip_address'] ) ? $asenha_limit_login['ip_address'] : '';
|
|
$request_uri = isset( $asenha_limit_login['request_uri'] ) ? $asenha_limit_login['request_uri'] : '';
|
|
$login_fails_allowed = isset( $asenha_limit_login['login_fails_allowed'] ) ? $asenha_limit_login['login_fails_allowed'] : 3;
|
|
$login_lockout_maxcount = isset( $asenha_limit_login['login_lockout_maxcount'] ) ? $asenha_limit_login['login_lockout_maxcount'] : 3;
|
|
|
|
// Check if the IP address has been used in a failed login attempt before, i.e. has it been recorded in the database?
|
|
$sql = $wpdb->prepare( "SELECT * FROM `" . $table_name . "` WHERE `ip_address` = %s", $ip_address );
|
|
$result = $wpdb->get_results( $sql, ARRAY_A );
|
|
if ( $result ) {
|
|
$result_count = count( $result );
|
|
} else {
|
|
$result_count = 0;
|
|
}
|
|
|
|
// Update logged info for the IP address in the global variable
|
|
if ( $result ) {
|
|
$asenha_limit_login['ip_address_log'] = $result;
|
|
}
|
|
|
|
if ( $result_count == 0 ) { // IP address has not been recorded in the database.
|
|
|
|
$new_fail_count = 1;
|
|
$new_lockout_count = 0;
|
|
|
|
} else { // IP address has been recorded in the database.
|
|
|
|
$new_fail_count = $result[0]['fail_count'] + 1;
|
|
$new_lockout_count = floor( ( $result[0]['fail_count'] + 1 ) / $login_fails_allowed );
|
|
|
|
}
|
|
|
|
// Get the URL where login failed, i.e. where brute force attack might be happening
|
|
// $login_url = ( ! empty( $_SERVER['HTTPS'] ) ? 'https://' : 'http://') . sanitize_text_field( $_SERVER['HTTP_HOST'] ) . sanitize_text_field( $_SERVER['REQUEST_URI'] );
|
|
|
|
// Time stamps
|
|
$unixtime = time();
|
|
if ( function_exists( 'wp_date' ) ) {
|
|
$datetime_wp = wp_date( 'Y-m-d H:i:s', $unixtime );
|
|
} else {
|
|
$datetime_wp = date_i18n( 'Y-m-d H:i:s', $unixtime );
|
|
}
|
|
|
|
$data = array(
|
|
'ip_address' => $ip_address,
|
|
'username' => $username,
|
|
'fail_count' => $new_fail_count,
|
|
'lockout_count' => $new_lockout_count,
|
|
'request_uri' => $request_uri,
|
|
'unixtime' => $unixtime,
|
|
'datetime_wp' => $datetime_wp,
|
|
'info' => '',
|
|
);
|
|
|
|
$data_format = array(
|
|
'%s', // string
|
|
'%s', // string
|
|
'%d', // integer
|
|
'%d', // integer
|
|
'%s', // string
|
|
'%d', // integer
|
|
'%s', // string
|
|
'%s', // string
|
|
);
|
|
|
|
if ( $result_count == 0 ) {
|
|
|
|
// Insert into the database
|
|
$result = $wpdb->insert(
|
|
$table_name,
|
|
$data,
|
|
$data_format
|
|
);
|
|
|
|
} else {
|
|
|
|
$fail_count = $result[0]['fail_count'];
|
|
$lockout_count = $result[0]['lockout_count'];
|
|
$last_fail_on = $result[0]['unixtime'];
|
|
|
|
$where = array( 'ip_address' => $ip_address );
|
|
$where_format = array( '%s' );
|
|
|
|
// Failed attempts have been recorded and fulfills lockout condition
|
|
if ( ! empty( $fail_count )
|
|
&& ( $login_fails_allowed > 0 )
|
|
&& ( $fail_count % $login_fails_allowed == 0 )
|
|
) {
|
|
|
|
// Has reached max / gone beyond number of lockouts allowed?
|
|
if ( $lockout_count >= $login_lockout_maxcount ) {
|
|
$asenha_limit_login['extended_lockout'] = true;
|
|
$lockout_period = $asenha_limit_login['extended_lockout_period'];
|
|
} else {
|
|
$asenha_limit_login['extended_lockout'] = false;
|
|
$lockout_period = $asenha_limit_login['default_lockout_period'];
|
|
}
|
|
|
|
$asenha_limit_login['lockout_period'] = $lockout_period;
|
|
|
|
// User/visitor is still within the lockout period
|
|
if ( ( time() - $last_fail_on ) <= $lockout_period ) {
|
|
|
|
// Do nothing
|
|
|
|
} else {
|
|
|
|
if ( $lockout_count < $login_lockout_maxcount ) {
|
|
|
|
// Update existing data in the database
|
|
$wpdb->update(
|
|
$table_name,
|
|
$data,
|
|
$where,
|
|
$data_format,
|
|
$where_format
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// Update existing data in the database
|
|
$wpdb->update(
|
|
$table_name,
|
|
$data,
|
|
$where,
|
|
$data_format,
|
|
$where_format
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear failed login attempts log after successful login
|
|
*
|
|
* @since 2.5.0
|
|
*/
|
|
public function clear_failed_login_log() {
|
|
global $wpdb, $asenha_limit_login;
|
|
|
|
$table_name = $wpdb->prefix . 'asenha_failed_logins';
|
|
$ip_address = isset( $asenha_limit_login['ip_address'] ) ? $asenha_limit_login['ip_address'] : '';
|
|
|
|
// Remove the DB log entry for the current IP address.
|
|
|
|
$where = array( 'ip_address' => $ip_address );
|
|
$where_format = array( '%s' );
|
|
|
|
$wpdb->delete(
|
|
$table_name,
|
|
$where,
|
|
$where_format
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Trigger scheduling of failed login attempts log clean up event.
|
|
*
|
|
* @since 7.1.1
|
|
*/
|
|
public function trigger_clear_or_schedule_log_clean_up_by_amount( $option_name ) {
|
|
if ( ASENHA_SLUG_U === $option_name ) {
|
|
$this->clear_or_schedule_log_clean_up_by_amount();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule failed login attempts log clean up event
|
|
*
|
|
* @link https://plugins.trac.wordpress.org/browser/lana-email-logger/tags/1.1.0/lana-email-logger.php#L750
|
|
* @since 7.8.3
|
|
*/
|
|
public function clear_or_schedule_log_clean_up_by_amount() {
|
|
$options = get_option( ASENHA_SLUG_U, array() );
|
|
$limit_login_attempts = isset( $options['limit_login_attempts'] ) ? $options['limit_login_attempts'] : false;
|
|
$failed_login_attempts_log_schedule_cleanup_by_amount = isset( $options['failed_login_attempts_log_schedule_cleanup_by_amount'] ) ? $options['failed_login_attempts_log_schedule_cleanup_by_amount'] : false;
|
|
|
|
// If module or scheduled clean up is not enabled, clear the schedule.
|
|
if ( ! $limit_login_attempts || ! $failed_login_attempts_log_schedule_cleanup_by_amount ) {
|
|
wp_clear_scheduled_hook( 'asenha_failed_login_attempts_log_cleanup_by_amount' );
|
|
return;
|
|
}
|
|
|
|
// If there's no next scheduled clean up event, let's schedule one
|
|
if ( ! wp_next_scheduled( 'asenha_failed_login_attempts_log_cleanup_by_amount' ) ) {
|
|
wp_schedule_event( time(), 'hourly', 'asenha_failed_login_attempts_log_cleanup_by_amount' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform clean up of failed login attempts log by the amount of entries to keep
|
|
*
|
|
* @link https://plugins.trac.wordpress.org/browser/lana-email-logger/tags/1.1.0/lana-email-logger.php#L768
|
|
* @since 7.8.3
|
|
*/
|
|
public function perform_failed_login_attempts_log_clean_up_by_amount() {
|
|
global $wpdb;
|
|
|
|
$options = get_option( ASENHA_SLUG_U, array() );
|
|
$limit_login_attempts = isset( $options['limit_login_attempts'] ) ? $options['limit_login_attempts'] : false;
|
|
$failed_login_attempts_log_schedule_cleanup_by_amount = isset( $options['failed_login_attempts_log_schedule_cleanup_by_amount'] ) ? $options['failed_login_attempts_log_schedule_cleanup_by_amount'] : false;
|
|
$failed_login_attempts_log_entries_amount_to_keep = 1000;
|
|
|
|
// Bail and clear any orphan schedule if clean up should not run.
|
|
if ( ! $limit_login_attempts || ! $failed_login_attempts_log_schedule_cleanup_by_amount ) {
|
|
wp_clear_scheduled_hook( 'asenha_failed_login_attempts_log_cleanup_by_amount' );
|
|
return;
|
|
}
|
|
|
|
$table_name = $wpdb->prefix . 'asenha_failed_logins';
|
|
|
|
// Maybe create table if it does not exist yet, e.g. module enabled but no login attempt yet.
|
|
$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) );
|
|
|
|
if ( $wpdb->get_var( $query ) === $table_name ) {
|
|
// Table already exists, do nothing.
|
|
} else {
|
|
$activation = new Activation;
|
|
$activation->create_failed_logins_log_table();
|
|
}
|
|
|
|
$wpdb->query( "DELETE failed_login_entries FROM " . $table_name . "
|
|
AS failed_login_entries JOIN ( SELECT id FROM " . $table_name . " ORDER BY id DESC LIMIT 1 OFFSET " . $failed_login_attempts_log_entries_amount_to_keep . " )
|
|
AS failed_login_entries_limit ON failed_login_entries.id <= failed_login_entries_limit.id;" );
|
|
}
|
|
|
|
}
|