robotstxt-2fa/includes/class-email-notifications.php
2026-06-06 05:08:20 +00:00

421 lines
11 KiB
PHP

<?php
/**
* Email notifications for security-relevant 2FA events.
*
* @package Robotstxt_2FA
*/
namespace Robotstxt\TwoFA;
use Robotstxt\TwoFA\User\Two_Factor_Config;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Sends automatic email alerts for 2FA events and activity digests.
*/
class Email_Notifications {
/**
* Maximum number of known login contexts to retain per user.
*/
private const KNOWN_CONTEXTS_LIMIT = 50;
/**
* User meta key storing known login contexts.
*/
private const KNOWN_CONTEXTS_META = 'robotstxt_2fa_known_contexts';
/**
* Cron hook name for digest emails.
*/
private const DIGEST_CRON_HOOK = 'robotstxt_2fa_digest_cron';
/**
* Global two-factor configuration.
*
* @var Two_Factor_Config
*/
private Two_Factor_Config $config;
/**
* Constructor.
*
* @param Two_Factor_Config $config Global two-factor configuration.
*/
public function __construct( Two_Factor_Config $config ) {
$this->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<string, array{interval: int, display: string}> $schedules Existing cron schedules.
*
* @return array<string, array{interval: int, display: string}>
*/
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' ) );
}
}