421 lines
11 KiB
PHP
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' ) );
|
|
}
|
|
}
|