array( until, username, attempts ).
*/
const OPTION = 'ajsec_lockouts';
/**
* Hard cap on stored lockouts.
*/
const MAX_LOCKS = 1000;
/**
* Error code returned while locked.
*/
const ERROR = 'ajsec_locked_out';
/**
* {@inheritDoc}
*/
public function id() {
return 'login_lockout';
}
/**
* {@inheritDoc}
*/
public function label() {
return __( 'Login lockout', '007aj-security-scanner-firewall' );
}
/**
* {@inheritDoc}
*/
public function description() {
return __( 'Temporarily blocks an IP address after too many failed login attempts.', '007aj-security-scanner-firewall' );
}
/**
* {@inheritDoc}
*/
public function register() {
add_filter( 'authenticate', array( $this, 'authenticate' ), 99, 3 );
add_action( 'wp_login_failed', array( $this, 'failed' ), 10, 2 );
add_action( 'wp_login', array( $this, 'succeeded' ), 10, 1 );
add_filter( 'login_message', array( $this, 'login_message' ) );
}
/**
* Refuse the login while the IP is locked, even with a correct password. Runs after core's own
* checks so the result cannot be overridden by them.
*
* @param \WP_User|\WP_Error|null $user Result so far.
* @param string $username Username entered.
* @param string $password Password entered.
* @return \WP_User|\WP_Error|null
*/
public function authenticate( $user, $username = '', $password = '' ) {
if ( '' === (string) $username && '' === (string) $password ) {
return $user; // Plain page load of the login form; the lockout notice is shown by login_message().
}
$until = self::locked_until( Request::ip() );
if ( $until ) {
return new \WP_Error( self::ERROR, self::message( $until ) );
}
return $user;
}
/**
* Count a failed login.
*
* @param string $username Username or email entered.
* @param \WP_Error|null $error Error (WP 5.4+).
*/
public function failed( $username, $error = null ) {
$ip = Request::ip();
if ( '' === $ip ) {
return;
}
if ( $error instanceof \WP_Error && in_array( self::ERROR, $error->get_error_codes(), true ) ) {
return; // Already locked; the attempt was refused before any password check counted.
}
if ( self::locked_until( $ip ) ) {
return;
}
Stats::bump( 'failed_logins' );
$key = 'ajsec_ll_' . Request::ip_key( $ip );
$window = Settings::int( 'lockout_window' ) * MINUTE_IN_SECONDS;
$state = get_transient( $key );
$now = time();
$state = ( is_array( $state ) && isset( $state['first'] ) && ( $now - (int) $state['first'] ) < $window ) ? $state : array(
'count' => 0,
'first' => $now,
);
$state['count'] = (int) $state['count'] + 1;
if ( $state['count'] >= Settings::int( 'lockout_attempts' ) ) {
delete_transient( $key );
self::lock( $ip, (string) $username, $state['count'] );
return;
}
set_transient( $key, $state, max( 60, $window - ( $now - (int) $state['first'] ) ) );
}
/**
* Successful login: reset the counter for this IP. (Logging is done by ActivityLog.)
*
* @param string $user_login Username.
*/
public function succeeded( $user_login ) {
$ip = Request::ip();
if ( '' !== $ip ) {
delete_transient( 'ajsec_ll_' . Request::ip_key( $ip ) );
}
}
/**
* Show the lockout on the login form itself.
*
* @param string $message Existing message HTML.
* @return string
*/
public function login_message( $message ) {
$until = self::locked_until( Request::ip() );
if ( $until ) {
$message .= '
' . esc_html( self::message( $until ) ) . '
';
}
return $message;
}
/**
* Lock an IP.
*
* @param string $ip IP address.
* @param string $username Last username tried.
* @param int $attempts Failed attempts counted.
*/
public static function lock( $ip, $username, $attempts ) {
$minutes = Settings::int( 'lockout_duration' );
$locks = self::locks();
$locks[ $ip ] = array(
'until' => time() + $minutes * MINUTE_IN_SECONDS,
'username' => Log::text( sanitize_user( Log::text( $username, 400 ), false ), 100 ),
'attempts' => (int) $attempts,
);
if ( count( $locks ) > self::MAX_LOCKS ) {
uasort(
$locks,
static function ( $a, $b ) {
return $a['until'] - $b['until'];
}
);
$locks = array_slice( $locks, -self::MAX_LOCKS, null, true );
}
update_option( self::OPTION, $locks, false );
Stats::bump( 'lockouts' );
/* translators: 1: number of failed attempts, 2: lockout length in minutes. */
Log::add( 'lockout', sprintf( __( '%1$d failed attempts; locked for %2$d minutes', '007aj-security-scanner-firewall' ), $attempts, $minutes ), 0, $username, $ip );
if ( Settings::get( 'lockout_email' ) ) {
self::email( $ip, $username, $attempts, $minutes );
}
}
/**
* Remove a lockout.
*
* @param string $ip IP address.
* @return bool Whether a lockout was removed.
*/
public static function unlock( $ip ) {
$locks = self::locks();
if ( ! isset( $locks[ $ip ] ) ) {
return false;
}
unset( $locks[ $ip ] );
update_option( self::OPTION, $locks, false );
delete_transient( 'ajsec_ll_' . Request::ip_key( $ip ) );
Log::add( 'lockout_cleared', __( 'Removed by an administrator', '007aj-security-scanner-firewall' ), get_current_user_id(), '', $ip );
return true;
}
/**
* Active lockouts (expired ones dropped).
*
* @return array
*/
public static function locks() {
$locks = get_option( self::OPTION, array() );
$locks = is_array( $locks ) ? $locks : array();
$now = time();
$live = array();
foreach ( $locks as $ip => $row ) {
if ( is_array( $row ) && isset( $row['until'] ) && (int) $row['until'] > $now ) {
$live[ $ip ] = $row;
}
}
if ( count( $live ) !== count( $locks ) ) {
update_option( self::OPTION, $live, false );
}
return $live;
}
/**
* Lockout expiry for an IP, or 0.
*
* @param string $ip IP address.
* @return int
*/
public static function locked_until( $ip ) {
if ( '' === $ip ) {
return 0;
}
$locks = self::locks();
return isset( $locks[ $ip ] ) ? (int) $locks[ $ip ]['until'] : 0;
}
/**
* Message shown to a locked visitor.
*
* @param int $until Expiry timestamp.
* @return string
*/
private static function message( $until ) {
$minutes = max( 1, (int) ceil( ( $until - time() ) / MINUTE_IN_SECONDS ) );
/* translators: %d: minutes until the lockout ends. */
return sprintf( _n( 'Too many failed login attempts. Please try again in %d minute.', 'Too many failed login attempts. Please try again in %d minutes.', $minutes, '007aj-security-scanner-firewall' ), $minutes );
}
/**
* Email the site admin about a lockout. At most one email per 15 minutes.
*
* @param string $ip IP address.
* @param string $username Last username tried.
* @param int $attempts Attempts counted.
* @param int $minutes Lockout length.
*/
private static function email( $ip, $username, $attempts, $minutes ) {
if ( get_transient( 'ajsec_ll_mail' ) ) {
return;
}
set_transient( 'ajsec_ll_mail', 1, 15 * MINUTE_IN_SECONDS );
$site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
/* translators: %s: site name. */
$subject = sprintf( __( '[%s] Login lockout', '007aj-security-scanner-firewall' ), $site );
$lines = array(
/* translators: %s: site URL. */
sprintf( __( 'A login lockout was triggered on %s.', '007aj-security-scanner-firewall' ), home_url( '/' ) ),
'',
/* translators: %s: IP address. */
sprintf( __( 'IP address: %s', '007aj-security-scanner-firewall' ), $ip ),
/* translators: %s: username. */
sprintf( __( 'Last username tried: %s', '007aj-security-scanner-firewall' ), '' !== $username ? $username : '-' ),
/* translators: %d: number of attempts. */
sprintf( __( 'Failed attempts: %d', '007aj-security-scanner-firewall' ), $attempts ),
/* translators: %d: minutes. */
sprintf( __( 'Locked for: %d minutes', '007aj-security-scanner-firewall' ), $minutes ),
'',
__( 'Further lockouts in the next 15 minutes are not emailed. You can review them in the plugin\'s activity log, if it is turned on.', '007aj-security-scanner-firewall' ),
);
wp_mail( get_option( 'admin_email' ), $subject, implode( "\n", $lines ) );
}
}