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 ) ); } }