config = $config; } /** * Register WordPress hooks. * * @since 1.3.0 * * @return void */ public function register_hooks(): void { add_action( 'robotstxt_2fa_method_enabled', array( $this, 'on_method_enabled' ), 10, 2 ); add_action( 'robotstxt_2fa_verification_success', array( $this, 'on_verification_success' ), 10, 2 ); add_action( self::DIGEST_CRON_HOOK, array( $this, 'send_digest' ) ); add_filter( 'cron_schedules', array( $this, 'add_cron_schedules' ) ); add_action( 'init', array( $this, 'maybe_schedule_digest' ) ); } /** * Register a custom monthly cron schedule. * * @since 1.3.0 * * @param array $schedules Existing cron schedules. * * @return array */ public function add_cron_schedules( array $schedules ): array { $schedules['robotstxt_2fa_monthly'] = array( 'interval' => 2592000, 'display' => __( 'Every 30 days', 'robotstxt-2fa' ), ); return $schedules; } /** * Schedule or unschedule the digest cron event based on settings. * * Also reschedules when the frequency setting has changed. * * @since 1.3.0 * * @return void */ public function maybe_schedule_digest(): void { $notifications = $this->config->get_notifications(); $digest_enabled = $notifications['digest_enabled']; if ( $digest_enabled ) { $interval = 'monthly' === $notifications['digest_frequency'] ? 'robotstxt_2fa_monthly' : 'weekly'; $scheduled_event = wp_get_scheduled_event( self::DIGEST_CRON_HOOK ); if ( false === $scheduled_event ) { wp_schedule_event( time() + DAY_IN_SECONDS, $interval, self::DIGEST_CRON_HOOK ); } elseif ( isset( $scheduled_event->schedule ) && is_string( $scheduled_event->schedule ) && $scheduled_event->schedule !== $interval ) { wp_clear_scheduled_hook( self::DIGEST_CRON_HOOK ); wp_schedule_event( time() + DAY_IN_SECONDS, $interval, self::DIGEST_CRON_HOOK ); } } elseif ( (bool) wp_next_scheduled( self::DIGEST_CRON_HOOK ) ) { wp_clear_scheduled_hook( self::DIGEST_CRON_HOOK ); } } /** * Send a notification when an administrator enables 2FA for a user. * * @since 1.3.0 * * @param \WP_User $user User for whom 2FA was enabled. * @param string $method Verification method that was enabled. * * @return void */ public function on_method_enabled( \WP_User $user, string $method ): void { unset( $method ); $notifications = $this->config->get_notifications(); if ( ! $notifications['on_enable'] ) { return; } // Only notify when an admin enables it for someone else. if ( get_current_user_id() === $user->ID ) { return; } $site_name = get_bloginfo( 'name' ); $profile_url = wp_login_url(); $subject = sprintf( /* translators: %s: site name. */ __( '[%s] Two-factor authentication has been activated on your account', 'robotstxt-2fa' ), $site_name ); $message = sprintf( /* translators: 1: display name, 2: site name, 3: login URL. */ __( 'Hi %1$s, An administrator has enabled two-factor authentication on your account at %2$s. You can review and configure your two-factor preferences from your profile page: %3$s If you did not expect this change, please contact the site administrator. -- %2$s', 'robotstxt-2fa' ), $user->display_name, $site_name, $profile_url ); $this->send_mail( $user->user_email, $subject, $message ); } /** * Handle post-verification notifications (new location or recovery code used). * * @since 1.3.0 * * @param \WP_User $user Authenticated user. * @param string $method Verification method that succeeded. * * @return void */ public function on_verification_success( \WP_User $user, string $method ): void { $notifications = $this->config->get_notifications(); $context_key = $this->get_login_context_key(); // New location / IP check. if ( $notifications['on_new_ip'] ) { $known_raw = get_user_meta( $user->ID, self::KNOWN_CONTEXTS_META, true ); $known = is_array( $known_raw ) ? $known_raw : array(); if ( ! in_array( $context_key, $known, true ) ) { $site_name = get_bloginfo( 'name' ); $raw_ip = ''; if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below. $raw_ip = is_string( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : ''; $raw_ip = sanitize_text_field( wp_unslash( $raw_ip ) ); } $anon_ip = wp_privacy_anonymize_ip( $raw_ip ); $subject = sprintf( /* translators: %s: site name. */ __( '[%s] New login to your account', 'robotstxt-2fa' ), $site_name ); $message = sprintf( /* translators: 1: display name, 2: site name, 3: date/time, 4: anonymized IP. */ __( 'Hi %1$s, A new login to your account at %2$s was detected from an unrecognised browser or location. Date/time: %3$s IP address: %4$s If this was you, no action is needed. If you do not recognise this login, please change your password and contact the site administrator immediately. -- %2$s', 'robotstxt-2fa' ), $user->display_name, $site_name, gmdate( 'Y-m-d H:i:s T' ), $anon_ip ); $this->send_mail( $user->user_email, $subject, $message ); // Store new context. $known[] = $context_key; if ( count( $known ) > self::KNOWN_CONTEXTS_LIMIT ) { $known = array_slice( $known, - self::KNOWN_CONTEXTS_LIMIT ); } update_user_meta( $user->ID, self::KNOWN_CONTEXTS_META, $known ); } } // Recovery code used. if ( 'recovery' === $method ) { $site_name = get_bloginfo( 'name' ); if ( $notifications['on_recovery_used'] ) { $subject = sprintf( /* translators: %s: site name. */ __( '[%s] A recovery code was used to access your account', 'robotstxt-2fa' ), $site_name ); $message = sprintf( /* translators: 1: display name, 2: site name, 3: date/time. */ __( 'Hi %1$s, One of your two-factor recovery codes was used to log in to %2$s. Date/time: %3$s If this was you, no action is needed. Recovery codes are single-use; the code has now been consumed. If you do not recognise this activity, please contact your site administrator immediately. -- %2$s', 'robotstxt-2fa' ), $user->display_name, $site_name, gmdate( 'Y-m-d H:i:s T' ) ); $this->send_mail( $user->user_email, $subject, $message ); } if ( $notifications['on_recovery_used_admin'] ) { $admin_email = get_option( 'admin_email' ); if ( is_string( $admin_email ) && '' !== $admin_email ) { $subject = sprintf( /* translators: %s: site name. */ __( '[%s] A recovery code was used — administrator notice', 'robotstxt-2fa' ), $site_name ); $message = sprintf( /* translators: 1: user display name, 2: user login, 3: site name, 4: date/time. */ __( 'Administrator notice from %3$s: User "%1$s" (%2$s) used a recovery code to log in. Date/time: %4$s Please verify this was an expected action. -- %3$s', 'robotstxt-2fa' ), $user->display_name, $user->user_login, $site_name, gmdate( 'Y-m-d H:i:s T' ) ); $this->send_mail( $admin_email, $subject, $message ); } } } } /** * Send the periodic activity digest email to all administrators. * * @since 1.3.0 * * @return void */ public function send_digest(): void { $site_name = get_bloginfo( 'name' ); $count_result = count_users(); $total_users = $count_result['total_users']; $query_2fa = new \WP_User_Query( array( 'meta_key' => 'robotstxt_2fa_user_settings', 'meta_compare' => 'EXISTS', 'fields' => 'ID', 'number' => -1, ) ); $users_with_2fa = $query_2fa->get_total(); // Count failed attempts in the last 7 days. $log = get_option( 'robotstxt_2fa_failed_log', array() ); $log = is_array( $log ) ? $log : array(); $week_ago = time() - WEEK_IN_SECONDS; $recent_fails = 0; foreach ( $log as $entry ) { if ( is_array( $entry ) && isset( $entry['timestamp'] ) && is_numeric( $entry['timestamp'] ) && (int) $entry['timestamp'] >= $week_ago ) { ++$recent_fails; } } $subject = sprintf( /* translators: %s: site name. */ __( '2FA activity digest — %s', 'robotstxt-2fa' ), $site_name ); $message = sprintf( /* translators: 1: site name, 2: total users, 3: users with 2FA, 4: failed attempts last 7 days. */ __( 'Two-factor authentication activity digest for %1$s Total registered users: %2$d Users with 2FA configured: %3$d Failed 2FA attempts (last 7 days): %4$d -- %1$s', 'robotstxt-2fa' ), $site_name, $total_users, $users_with_2fa, $recent_fails ); $admins = get_users( array( 'capability' => 'manage_options' ) ); foreach ( $admins as $admin ) { $this->send_mail( $admin->user_email, $subject, $message ); } } /** * Build a hashed context key representing the current login environment. * * @since 1.3.0 * * @return string */ private function get_login_context_key(): string { $ip = ''; if ( isset( $_SERVER['REMOTE_ADDR'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below. $raw_addr = is_string( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : ''; $ip = sanitize_text_field( wp_unslash( $raw_addr ) ); $ip = wp_privacy_anonymize_ip( $ip ); } $user_agent = ''; if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below. $raw_ua = is_string( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : ''; $user_agent = sanitize_text_field( wp_unslash( $raw_ua ) ); } $user_agent = strtolower( substr( $user_agent, 0, 255 ) ); if ( '' === $ip && '' === $user_agent ) { return sha1( 'unknown' ); } return sha1( $ip . '|' . $user_agent ); } /** * Send an email with a plain-text content type. * * @since 1.3.0 * * @param string $to Recipient email address. * @param string $subject Email subject. * @param string $message Email body. * * @return void */ private function send_mail( string $to, string $subject, string $message ): void { wp_mail( $to, $subject, $message, array( 'Content-Type: text/plain; charset=UTF-8' ) ); } }