1, 'password_days' => 180, 'inactivity_enabled' => 1, 'inactivity_days' => 90, 'throttle_enabled' => 1, 'throttle_max_attempts' => 5, 'throttle_lockout_minutes' => 15, 'twofa_required' => 0, 'complexity_enabled' => 1, 'complexity_min_length' => 10, 'complexity_require_mixed_case' => 1, 'complexity_require_number' => 1, 'complexity_require_symbol' => 0, 'login_alert_enabled' => 1, 'rest_restrict_users' => 0, 'notify_enabled' => 1, 'notify_user_created' => 1, 'notify_user_login' => 1, 'notify_username_changed' => 1, 'notify_email_changed' => 1, 'notify_password_changed' => 1, 'notify_recipients' => 'all', 'notify_recipient_ids' => array(), 'email_subject' => __( '[{{site_name}}] {{event}}: {{username}}', 'aegis-user-guard' ), 'email_body' => __( '

A user account security event was detected.

Please review the account details below and confirm that this activity was expected. If you do not recognize it, follow your site’s incident-response process.

', 'aegis-user-guard' ), ); } /** * Handle the settings callback. */ public static function settings() { if ( null === self::$settings ) { self::$settings = wp_parse_args( (array) get_option( self::OPTION, array() ), self::defaults() ); } return self::$settings; } /** * Handle the register_admin_menu callback. */ public static function register_admin_menu() { add_menu_page( __( 'Aegis User Guard', 'aegis-user-guard' ), __( 'Aegis User Guard', 'aegis-user-guard' ), 'manage_options', self::PAGE_SLUG, array( __CLASS__, 'render_settings_page' ), 'dashicons-shield', 80 ); } /** * Handle the add_settings_link callback. * * @param mixed $links Parameter passed to the callback. */ public static function add_settings_link( $links ) { if ( ! current_user_can( 'manage_options' ) ) { return $links; } $links['settings'] = sprintf( '%s', esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ), esc_html__( 'Settings', 'aegis-user-guard' ) ); return $links; } /** * Handle the register_settings callback. */ public static function register_settings() { register_setting( self::OPTION_GROUP, self::OPTION, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ), 'default' => self::defaults(), ) ); } /** * Sanitize and normalize submitted plugin settings. * * @param mixed $input Submitted settings. * @return array */ public static function sanitize_settings( $input ) { $input = is_array( $input ) ? $input : array(); $old_settings = wp_parse_args( (array) get_option( self::OPTION, array() ), self::defaults() ); $settings = self::defaults(); $settings['password_enabled'] = empty( $input['password_enabled'] ) ? 0 : 1; $settings['inactivity_enabled'] = empty( $input['inactivity_enabled'] ) ? 0 : 1; $settings['throttle_enabled'] = empty( $input['throttle_enabled'] ) ? 0 : 1; $settings['twofa_required'] = empty( $input['twofa_required'] ) ? 0 : 1; $settings['complexity_enabled'] = empty( $input['complexity_enabled'] ) ? 0 : 1; $settings['complexity_min_length'] = max( 6, min( 64, absint( $input['complexity_min_length'] ?? 10 ) ) ); $settings['complexity_require_mixed_case'] = empty( $input['complexity_require_mixed_case'] ) ? 0 : 1; $settings['complexity_require_number'] = empty( $input['complexity_require_number'] ) ? 0 : 1; $settings['complexity_require_symbol'] = empty( $input['complexity_require_symbol'] ) ? 0 : 1; $settings['login_alert_enabled'] = empty( $input['login_alert_enabled'] ) ? 0 : 1; $settings['rest_restrict_users'] = empty( $input['rest_restrict_users'] ) ? 0 : 1; $settings['notify_enabled'] = empty( $input['notify_enabled'] ) ? 0 : 1; $settings['notify_user_created'] = empty( $input['notify_user_created'] ) ? 0 : 1; $settings['notify_user_login'] = empty( $input['notify_user_login'] ) ? 0 : 1; $settings['notify_username_changed'] = empty( $input['notify_username_changed'] ) ? 0 : 1; $settings['notify_email_changed'] = empty( $input['notify_email_changed'] ) ? 0 : 1; $settings['notify_password_changed'] = empty( $input['notify_password_changed'] ) ? 0 : 1; $settings['notify_recipients'] = ( isset( $input['notify_recipients'] ) && 'selected' === sanitize_key( (string) $input['notify_recipients'] ) ) ? 'selected' : 'all'; $settings['notify_recipient_ids'] = array(); if ( 'selected' === $settings['notify_recipients'] ) { $recipient_ids = ! empty( $input['notify_recipient_ids'] ) && is_array( $input['notify_recipient_ids'] ) ? array_filter( array_map( 'absint', $input['notify_recipient_ids'] ), static function ( $id ) { return $id > 0; } ) : array(); $administrator_ids = get_users( array( 'role' => 'administrator', 'fields' => 'ID', ) ); $administrator_ids = array_map( 'absint', is_array( $administrator_ids ) ? $administrator_ids : array() ); $settings['notify_recipient_ids'] = array_values( array_intersect( array_unique( $recipient_ids ), $administrator_ids ) ); if ( empty( $settings['notify_recipient_ids'] ) ) { $settings['notify_recipients'] = 'all'; } } $settings['password_days'] = max( 1, min( 3650, absint( $input['password_days'] ?? 180 ) ) ); $settings['inactivity_days'] = max( 1, min( 3650, absint( $input['inactivity_days'] ?? 90 ) ) ); $settings['throttle_max_attempts'] = max( 3, min( 20, absint( $input['throttle_max_attempts'] ?? 5 ) ) ); $settings['throttle_lockout_minutes'] = max( 1, min( 1440, absint( $input['throttle_lockout_minutes'] ?? 15 ) ) ); $settings['email_subject'] = sanitize_text_field( $input['email_subject'] ?? self::defaults()['email_subject'] ); $settings['email_body'] = wp_kses_post( $input['email_body'] ?? self::defaults()['email_body'] ); self::$settings = $settings; self::log_activity( 'policy_updated', 0, self::diff_settings( $old_settings, $settings ) ); return $settings; } /** * Handle the diff_settings callback. * * @param mixed $old Parameter passed to the callback. * @param mixed $new_settings New plugin settings. */ private static function diff_settings( $old, $new_settings ) { $fields = array( 'password_enabled' => array( __( 'Password freshness rule', 'aegis-user-guard' ), 'bool' ), 'password_days' => array( __( 'Password freshness age', 'aegis-user-guard' ), 'days' ), 'inactivity_enabled' => array( __( 'Inactivity lockout rule', 'aegis-user-guard' ), 'bool' ), 'inactivity_days' => array( __( 'Inactivity lockout age', 'aegis-user-guard' ), 'days' ), 'throttle_enabled' => array( __( 'Brute-force lockout rule', 'aegis-user-guard' ), 'bool' ), 'throttle_max_attempts' => array( __( 'Brute-force attempt limit', 'aegis-user-guard' ), 'text' ), 'throttle_lockout_minutes' => array( __( 'Brute-force lockout length', 'aegis-user-guard' ), 'minutes' ), 'twofa_required' => array( __( 'Two-factor requirement', 'aegis-user-guard' ), 'bool' ), 'complexity_enabled' => array( __( 'Password complexity rule', 'aegis-user-guard' ), 'bool' ), 'complexity_min_length' => array( __( 'Password minimum length', 'aegis-user-guard' ), 'text' ), 'complexity_require_mixed_case' => array( __( 'Require mixed case', 'aegis-user-guard' ), 'bool' ), 'complexity_require_number' => array( __( 'Require a number', 'aegis-user-guard' ), 'bool' ), 'complexity_require_symbol' => array( __( 'Require a symbol', 'aegis-user-guard' ), 'bool' ), 'login_alert_enabled' => array( __( 'New-device sign-in alerts', 'aegis-user-guard' ), 'bool' ), 'rest_restrict_users' => array( __( 'REST user list restriction', 'aegis-user-guard' ), 'bool' ), 'notify_enabled' => array( __( 'Administrator alerts', 'aegis-user-guard' ), 'bool' ), 'notify_user_created' => array( __( 'New account alerts', 'aegis-user-guard' ), 'bool' ), 'notify_user_login' => array( __( 'Sign-in alerts', 'aegis-user-guard' ), 'bool' ), 'notify_username_changed' => array( __( 'Username change alerts', 'aegis-user-guard' ), 'bool' ), 'notify_email_changed' => array( __( 'Email change alerts', 'aegis-user-guard' ), 'bool' ), 'notify_password_changed' => array( __( 'Password change alerts', 'aegis-user-guard' ), 'bool' ), 'notify_recipients' => array( __( 'Alert recipients mode', 'aegis-user-guard' ), 'text' ), 'notify_recipient_ids' => array( __( 'Selected alert recipients', 'aegis-user-guard' ), 'count' ), 'email_subject' => array( __( 'Email subject', 'aegis-user-guard' ), 'changed' ), 'email_body' => array( __( 'Email message', 'aegis-user-guard' ), 'changed' ), ); $changes = array(); foreach ( $fields as $key => $meta ) { list( $label, $type ) = $meta; $old_value = $old[ $key ] ?? null; $new_value = $new_settings[ $key ] ?? null; if ( 'count' === $type ) { $old_value = is_array( $old_value ) ? $old_value : array(); $new_value = is_array( $new_value ) ? $new_value : array(); if ( $old_value === $new_value ) { continue; } $changes[ $key ] = sprintf( '%s: %d → %d', $label, count( $old_value ), count( $new_value ) ); continue; } if ( $old_value === $new_value ) { continue; } switch ( $type ) { case 'bool': $changes[ $key ] = sprintf( '%s: %s → %s', $label, $old_value ? __( 'on', 'aegis-user-guard' ) : __( 'off', 'aegis-user-guard' ), $new_value ? __( 'on', 'aegis-user-guard' ) : __( 'off', 'aegis-user-guard' ) ); break; case 'days': $changes[ $key ] = sprintf( '%s: %d → %d %s', $label, $old_value, $new_value, __( 'days', 'aegis-user-guard' ) ); break; case 'minutes': $changes[ $key ] = sprintf( '%s: %d → %d %s', $label, $old_value, $new_value, __( 'minutes', 'aegis-user-guard' ) ); break; case 'changed': $changes[ $key ] = sprintf( '%s %s', $label, __( 'updated', 'aegis-user-guard' ) ); break; default: $changes[ $key ] = sprintf( '%s: %s → %s', $label, $old_value, $new_value ); } } return $changes; } /** * Handle the ajax_save_settings callback. */ public static function ajax_save_settings() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to update Aegis settings.', 'aegis-user-guard' ) ), 403 ); } check_ajax_referer( 'aegis_user_guard_save', 'nonce' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_settings() validates and sanitizes each setting according to its type. $input = isset( $_POST[ self::OPTION ] ) && is_array( $_POST[ self::OPTION ] ) ? wp_unslash( $_POST[ self::OPTION ] ) : array(); $settings = self::sanitize_settings( $input ); update_option( self::OPTION, $settings ); wp_send_json_success( array( 'message' => __( 'Aegis settings saved.', 'aegis-user-guard' ), 'version' => self::VERSION, ) ); } /** * Handle the ajax_get_administrators callback. */ public static function ajax_get_administrators() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to view Administrator accounts.', 'aegis-user-guard' ) ), 403 ); } check_ajax_referer( 'aegis_user_guard_directory', 'nonce' ); $page = isset( $_POST['page'] ) ? max( 1, absint( $_POST['page'] ) ) : 1; $per_page = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 10; $per_page = in_array( $per_page, array( 5, 10, 25, 50 ), true ) ? $per_page : 10; $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : ''; $query_args = array( 'role' => 'administrator', 'number' => $per_page, 'offset' => ( $page - 1 ) * $per_page, 'orderby' => 'display_name', 'order' => 'ASC', 'count_total' => true, ); if ( '' !== $search ) { $query_args['search'] = '*' . $search . '*'; $query_args['search_columns'] = array( 'user_login', 'user_email', 'display_name' ); } $user_query = new WP_User_Query( $query_args ); $total = (int) $user_query->get_total(); $total_pages = max( 1, (int) ceil( $total / $per_page ) ); $page = min( $page, $total_pages ); $expected_offset = ( $page - 1 ) * $per_page; if ( $expected_offset !== (int) $query_args['offset'] ) { $query_args['offset'] = $expected_offset; $user_query = new WP_User_Query( $query_args ); } $rows = array(); foreach ( (array) $user_query->get_results() as $administrator ) { $rows[] = self::administrator_directory_row( $administrator ); } wp_send_json_success( array( 'rows' => implode( '', $rows ), 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => $total_pages, /* translators: %1$s is the first result, %2$s is the last result, and %3$s is the total. */ 'count_text' => sprintf( __( 'Showing %1$s–%2$s of %3$s', 'aegis-user-guard' ), $total ? ( ( $page - 1 ) * $per_page ) + 1 : 0, min( $page * $per_page, $total ), $total ), ) ); } /** * Handle the get_pending_administrators callback. */ private static function get_pending_administrators() { return get_users( array( 'role' => 'administrator', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The pending-account policy intentionally queries user metadata. 'meta_query' => array( array( 'key' => self::META_PENDING_ADMIN, 'value' => 1, 'compare' => '=', ), ), 'orderby' => 'registered', 'order' => 'DESC', ) ); } /** * Handle the is_pending_admin_review callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function is_pending_admin_review( $user_id ) { return self::user_is_administrator( $user_id ) && (bool) get_user_meta( $user_id, self::META_PENDING_ADMIN, true ); } /** * Handle the set_pending_admin_review callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $pending Parameter passed to the callback. */ private static function set_pending_admin_review( $user_id, $pending = true ) { if ( $pending ) { update_user_meta( $user_id, self::META_PENDING_ADMIN, 1 ); self::set_account_disabled( $user_id, true ); return; } delete_user_meta( $user_id, self::META_PENDING_ADMIN ); self::set_account_disabled( $user_id, false ); } /** * Handle the ajax_get_pending_admin callback. */ public static function ajax_get_pending_admin() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to review Administrator accounts.', 'aegis-user-guard' ) ), 403 ); } check_ajax_referer( 'aegis_user_guard_pending', 'nonce' ); $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; $user = $user_id ? get_userdata( $user_id ) : false; if ( ! $user || ! self::is_pending_admin_review( $user_id ) ) { wp_send_json_error( array( 'message' => __( 'This Administrator is no longer awaiting review.', 'aegis-user-guard' ) ), 404 ); } $registered = $user->user_registered ? strtotime( $user->user_registered ) : 0; wp_send_json_success( array( 'id' => $user->ID, 'name' => $user->display_name ? $user->display_name : $user->user_login, 'username' => $user->user_login, 'email' => $user->user_email, 'registered' => $registered ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $registered ) : __( 'Not recorded', 'aegis-user-guard' ), 'role' => __( 'Administrator', 'aegis-user-guard' ), 'status' => __( 'Pending review', 'aegis-user-guard' ), 'login_status' => __( 'Blocked until access is granted', 'aegis-user-guard' ), 'profile_url' => admin_url( 'user-edit.php?user_id=' . $user->ID ), ) ); } /** * Handle the ajax_grant_admin_access callback. */ public static function ajax_grant_admin_access() { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to grant Administrator access.', 'aegis-user-guard' ) ), 403 ); } check_ajax_referer( 'aegis_user_guard_pending', 'nonce' ); $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; $user = $user_id ? get_userdata( $user_id ) : false; if ( ! $user || ! self::is_pending_admin_review( $user_id ) ) { wp_send_json_error( array( 'message' => __( 'This Administrator is no longer awaiting review.', 'aegis-user-guard' ) ), 404 ); } if ( get_current_user_id() === $user_id ) { wp_send_json_error( array( 'message' => __( 'You cannot grant access to your own pending account.', 'aegis-user-guard' ) ), 400 ); } delete_user_meta( $user_id, self::META_PENDING_ADMIN ); delete_user_meta( $user_id, self::META_DISABLED ); self::log_activity( 'administrator_access_granted', $user_id, array( 'source' => 'pending_review', 'status' => 'enabled', 'login' => 'available', ) ); wp_send_json_success( array( 'id' => $user_id, /* translators: %s is the display name or login of the administrator. */ 'message' => sprintf( __( 'Access granted to %s.', 'aegis-user-guard' ), $user->display_name ? $user->display_name : $user->user_login ), 'pending' => count( self::get_pending_administrators() ), ) ); } /** * Record a security event in the activity log. * * @param mixed $event Event identifier. * @param mixed $user_id Related user ID. * @param mixed $context Additional event data. */ private static function log_activity( $event, $user_id = 0, $context = array() ) { $log = get_option( self::OPTION_ACTIVITY, array() ); $log = is_array( $log ) ? $log : array(); $actor_id = is_user_logged_in() ? get_current_user_id() : 0; $entry = array( 'id' => wp_generate_uuid4(), 'time' => time(), 'event' => sanitize_key( $event ), 'user_id' => absint( $user_id ), 'actor_id' => absint( $actor_id ), 'context' => is_array( $context ) ? array_map( 'sanitize_text_field', $context ) : array(), ); array_unshift( $log, $entry ); update_option( self::OPTION_ACTIVITY, array_slice( $log, 0, self::MAX_ACTIVITY ), false ); } /** * Handle the get_activity_log callback. */ private static function get_activity_log() { $log = get_option( self::OPTION_ACTIVITY, array() ); return is_array( $log ) ? array_slice( $log, 0, 50 ) : array(); } /** * Get presentation metadata for an activity event. * * @param mixed $event Event identifier. */ private static function activity_event_meta( $event ) { $events = array( 'administrator_access_granted' => array( 'label' => __( 'Administrator access granted', 'aegis-user-guard' ), 'tone' => 'success', 'priority' => 'high', ), 'login_success' => array( 'label' => __( 'Successful sign-in', 'aegis-user-guard' ), 'tone' => 'success', 'priority' => 'low', ), 'login_blocked' => array( 'label' => __( 'Sign-in blocked', 'aegis-user-guard' ), 'tone' => 'critical', 'priority' => 'critical', ), 'password_changed' => array( 'label' => __( 'Password changed', 'aegis-user-guard' ), 'tone' => 'success', 'priority' => 'medium', ), 'user_created' => array( 'label' => __( 'User created', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'medium', ), 'administrator_created' => array( 'label' => __( 'Administrator created', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), 'administrator_promoted' => array( 'label' => __( 'Administrator role granted', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), 'access_paused' => array( 'label' => __( 'Account access paused', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), 'access_reactivated' => array( 'label' => __( 'Account access reactivated', 'aegis-user-guard' ), 'tone' => 'success', 'priority' => 'medium', ), 'notification_sent' => array( 'label' => __( 'Administrator email sent', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'low', ), 'policy_updated' => array( 'label' => __( 'Security policy updated', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'medium', ), '2fa_enabled' => array( 'label' => __( 'Two-factor authentication enabled', 'aegis-user-guard' ), 'tone' => 'success', 'priority' => 'high', ), '2fa_disabled' => array( 'label' => __( 'Two-factor authentication disabled', 'aegis-user-guard' ), 'tone' => 'danger', 'priority' => 'critical', ), '2fa_backup_code_used' => array( 'label' => __( 'Two-factor backup code used', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), '2fa_backup_codes_regenerated' => array( 'label' => __( 'Two-factor backup codes regenerated', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'medium', ), 'new_device_login' => array( 'label' => __( 'Sign-in from a new device', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), 'password_reset_forced' => array( 'label' => __( 'Password reset forced by administrator', 'aegis-user-guard' ), 'tone' => 'danger', 'priority' => 'critical', ), 'sessions_revoked' => array( 'label' => __( 'Sessions signed out', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), 'ip_rule_added' => array( 'label' => __( 'IP rule added', 'aegis-user-guard' ), 'tone' => 'warning', 'priority' => 'high', ), 'ip_rule_removed' => array( 'label' => __( 'IP rule removed', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'medium', ), 'activity_exported' => array( 'label' => __( 'Activity log exported', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'low', ), 'administrators_exported' => array( 'label' => __( 'Administrator directory exported', 'aegis-user-guard' ), 'tone' => 'info', 'priority' => 'low', ), 'login_failed' => array( 'label' => __( 'Sign-in attempt failed', 'aegis-user-guard' ), 'tone' => 'danger', 'priority' => 'high', ), ); return $events[ $event ] ?? array( 'label' => __( 'Security event', 'aegis-user-guard' ), 'tone' => 'neutral', 'priority' => 'low', ); } /** * Handle the activity_label callback. * * @param mixed $event Parameter passed to the callback. */ private static function activity_label( $event ) { $meta = self::activity_event_meta( $event ); return $meta['label']; } /** * Format an activity context for the admin table and CSV export. * * @param mixed $event Event identifier. * @param mixed $context Event context values. */ private static function activity_context( $event, $context ) { $context = is_array( $context ) ? $context : array(); $summary = ''; $detail = ''; switch ( $event ) { case 'administrator_access_granted': $summary = __( 'Access approved from the pending Administrator review queue.', 'aegis-user-guard' ); $detail = __( 'The account can sign in now.', 'aegis-user-guard' ); break; case 'login_success': $summary = __( 'Authentication completed successfully.', 'aegis-user-guard' ); break; case 'login_blocked': $reasons = array( 'ip_lockout_active' => __( 'Sign-in blocked because this network is temporarily locked.', 'aegis-user-guard' ), 'account_lockout_active' => __( 'Sign-in blocked because this account is temporarily locked.', 'aegis-user-guard' ), 'pending_administrator_review' => __( 'Sign-in blocked while Administrator access awaits review.', 'aegis-user-guard' ), 'manual_pause' => __( 'Sign-in blocked because account access is paused.', 'aegis-user-guard' ), 'inactivity_lockout' => __( 'Sign-in blocked because the account was inactive for too long.', 'aegis-user-guard' ), 'ip_lockout_triggered' => __( 'Sign-in blocked after repeated failures from this IP address.', 'aegis-user-guard' ), 'account_lockout_triggered' => __( 'Sign-in blocked after repeated failures for this account.', 'aegis-user-guard' ), 'invalid_2fa_code' => __( 'Sign-in blocked because the two-factor code was invalid.', 'aegis-user-guard' ), 'ip_blocklist' => __( 'Sign-in blocked because this IP address is on the block list.', 'aegis-user-guard' ), ); $summary = $reasons[ $context['reason'] ?? '' ] ?? __( 'Sign-in was blocked by a security rule.', 'aegis-user-guard' ); if ( ! empty( $context['ip'] ) ) { /* translators: %s is an IP address. */ $detail = sprintf( __( 'Source IP: %s.', 'aegis-user-guard' ), sanitize_text_field( $context['ip'] ) ); } break; case 'password_changed': $summary = __( 'The account password was changed successfully.', 'aegis-user-guard' ); break; case 'user_created': $summary = __( 'A new WordPress user account was created.', 'aegis-user-guard' ); break; case 'administrator_created': case 'administrator_promoted': $summary = __( 'Administrator access is awaiting approval.', 'aegis-user-guard' ); $detail = __( 'Sign-in is blocked until another Administrator grants access.', 'aegis-user-guard' ); break; case 'access_paused': $summary = __( 'Account access was paused from the Administrator directory.', 'aegis-user-guard' ); $detail = __( 'All active sessions were terminated.', 'aegis-user-guard' ); break; case 'access_reactivated': $summary = __( 'Account access was restored from the Administrator directory.', 'aegis-user-guard' ); break; case 'notification_sent': $event_label = sanitize_text_field( $context['event'] ?? __( 'Security event', 'aegis-user-guard' ) ); $recipient_count = absint( $context['recipients'] ?? 0 ); $recipient_mode = 'selected' === ( $context['recipient_mode'] ?? '' ) ? __( 'selected administrators', 'aegis-user-guard' ) : __( 'all administrators', 'aegis-user-guard' ); $recipient_label = 1 === $recipient_count ? __( 'recipient', 'aegis-user-guard' ) : __( 'recipients', 'aegis-user-guard' ); $delivery_status = 'accepted' === ( $context['delivery'] ?? '' ) ? __( 'delivery accepted', 'aegis-user-guard' ) : __( 'delivery failed', 'aegis-user-guard' ); /* translators: %s is the notification event label. */ $summary = sprintf( __( 'Notification sent for: %s.', 'aegis-user-guard' ), $event_label ); /* translators: %1$d is the number of recipients, %2$s is singular or plural recipient text, %3$s is the recipient group, and %4$s is the delivery status. */ $detail = sprintf( __( '%1$d %2$s · %3$s · %4$s.', 'aegis-user-guard' ), $recipient_count, $recipient_label, $recipient_mode, $delivery_status ); break; case 'policy_updated': $changes = array(); foreach ( $context as $change ) { if ( '' !== (string) $change ) { $changes[] = sanitize_text_field( $change ); } } $summary = __( 'Security policy settings were updated.', 'aegis-user-guard' ); $detail = ! empty( $changes ) ? implode( ' · ', $changes ) : __( 'No individual setting changes were recorded.', 'aegis-user-guard' ); break; case '2fa_enabled': $summary = __( 'Two-factor authentication was enabled for this account.', 'aegis-user-guard' ); break; case '2fa_disabled': $summary = __( 'Two-factor authentication was disabled for this account.', 'aegis-user-guard' ); break; case '2fa_backup_code_used': $summary = __( 'A backup code was used to complete two-factor authentication.', 'aegis-user-guard' ); break; case '2fa_backup_codes_regenerated': $summary = __( 'A new set of two-factor backup codes was generated.', 'aegis-user-guard' ); break; case 'new_device_login': $summary = __( 'A sign-in came from a device or IP address not seen before.', 'aegis-user-guard' ); if ( ! empty( $context['ip'] ) ) { /* translators: %s is an IP address. */ $detail = sprintf( __( 'New source IP: %s.', 'aegis-user-guard' ), sanitize_text_field( $context['ip'] ) ); } break; case 'password_reset_forced': $summary = __( 'An administrator required this account to set a new password.', 'aegis-user-guard' ); $detail = __( 'All active sessions were terminated.', 'aegis-user-guard' ); break; case 'sessions_revoked': $summary = 'self' === ( $context['source'] ?? '' ) ? __( 'The user signed out all active sessions from their own account.', 'aegis-user-guard' ) : __( 'An administrator signed out all active sessions from the Administrator directory.', 'aegis-user-guard' ); break; case 'ip_rule_added': $mode = 'allow' === ( $context['mode'] ?? '' ) ? __( 'allow', 'aegis-user-guard' ) : __( 'block', 'aegis-user-guard' ); $summary = __( 'A network access rule was added.', 'aegis-user-guard' ); /* translators: %1$s is the access mode, %2$s is an IP address. */ $detail = sprintf( __( 'This rule will %1$s IP address %2$s.', 'aegis-user-guard' ), $mode, sanitize_text_field( $context['ip'] ?? __( 'the recorded address', 'aegis-user-guard' ) ) ); break; case 'ip_rule_removed': $summary = __( 'A network access rule was removed.', 'aegis-user-guard' ); if ( ! empty( $context['ip'] ) ) { /* translators: %s is an IP address. */ $detail = sprintf( __( 'Removed rule for IP address: %s.', 'aegis-user-guard' ), sanitize_text_field( $context['ip'] ) ); } break; case 'activity_exported': $row_count = absint( $context['rows'] ?? 0 ); $row_label = 1 === $row_count ? __( 'activity record', 'aegis-user-guard' ) : __( 'activity records', 'aegis-user-guard' ); /* translators: %1$d is the number of records, and %2$s is singular or plural activity record text. */ $summary = sprintf( __( '%1$d %2$s were exported as a CSV file.', 'aegis-user-guard' ), $row_count, $row_label ); break; case 'administrators_exported': $row_count = absint( $context['rows'] ?? 0 ); $row_label = 1 === $row_count ? __( 'administrator record', 'aegis-user-guard' ) : __( 'administrator records', 'aegis-user-guard' ); /* translators: %1$d is the number of records, and %2$s is singular or plural administrator record text. */ $summary = sprintf( __( '%1$d %2$s were exported as a CSV file.', 'aegis-user-guard' ), $row_count, $row_label ); break; case 'login_failed': $summary = __( 'A sign-in attempt failed before authentication completed.', 'aegis-user-guard' ); if ( ! empty( $context['ip'] ) ) { /* translators: %s is an IP address. */ $detail = sprintf( __( 'Source IP: %s.', 'aegis-user-guard' ), sanitize_text_field( $context['ip'] ) ); } if ( ! empty( $context['username'] ) ) { /* translators: %s is a submitted username. */ $detail = sprintf( __( 'Submitted username: %s.', 'aegis-user-guard' ), sanitize_text_field( $context['username'] ) ); } break; } if ( '' === $summary ) { $summary = __( 'A security event was recorded.', 'aegis-user-guard' ); } return array( 'summary' => $summary, 'detail' => $detail, ); } /** * Convert an activity context into a plain-text string. * * @param mixed $event Event identifier. * @param mixed $context Event context values. */ private static function activity_context_text( $event, $context ) { $parts = self::activity_context( $event, $context ); return implode( ' · ', array_filter( $parts ) ); } /** * Handle the clear_activity_log callback. */ public static function clear_activity_log() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to clear the activity log.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); update_option( self::OPTION_ACTIVITY, array(), false ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'activity_cleared', admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ) ); exit; } /** * Handle the enqueue_admin_assets callback. * * @param mixed $hook Parameter passed to the callback. */ public static function enqueue_admin_assets( $hook ) { if ( ! in_array( $hook, array( 'toplevel_page_' . self::PAGE_SLUG, 'users.php', 'profile.php' ), true ) ) { return; } $asset_url = plugin_dir_url( AEGIS_USER_GUARD_FILE ) . 'admin/'; if ( 'toplevel_page_' . self::PAGE_SLUG === $hook ) { wp_enqueue_editor(); } wp_enqueue_style( 'aegis-user-guard-admin', $asset_url . 'css/admin.css', array(), self::VERSION ); wp_enqueue_script( 'aegis-user-guard-admin', $asset_url . 'js/admin.js', array(), self::VERSION, true ); wp_localize_script( 'aegis-user-guard-admin', 'AegisUserGuard', array( 'confirmToggle' => __( 'Change this user’s account access?', 'aegis-user-guard' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aegis_user_guard_save' ), 'directoryNonce' => wp_create_nonce( 'aegis_user_guard_directory' ), 'pendingNonce' => wp_create_nonce( 'aegis_user_guard_pending' ), 'twoFaNonce' => wp_create_nonce( 'aegis_user_guard_2fa' ), 'savingLabel' => __( 'Saving changes…', 'aegis-user-guard' ), 'savedLabel' => __( 'Changes saved', 'aegis-user-guard' ), 'errorLabel' => __( 'Unable to save changes. Please try again.', 'aegis-user-guard' ), 'i18n' => self::admin_script_i18n(), ) ); } /** * Handle the admin_script_i18n callback. */ private static function admin_script_i18n() { return array( 'cancel' => __( 'Cancel', 'aegis-user-guard' ), 'working' => __( 'Working…', 'aegis-user-guard' ), 'close' => __( 'Close', 'aegis-user-guard' ), 'openProfile' => __( 'Open profile', 'aegis-user-guard' ), 'administratorAccountDetails' => __( 'Administrator account details', 'aegis-user-guard' ), 'reviewBeforeGrant' => __( 'Review this account before granting sign-in access.', 'aegis-user-guard' ), 'name' => __( 'Name', 'aegis-user-guard' ), 'username' => __( 'Username', 'aegis-user-guard' ), 'email' => __( 'Email', 'aegis-user-guard' ), 'role' => __( 'Role', 'aegis-user-guard' ), 'created' => __( 'Created', 'aegis-user-guard' ), 'loginStatus' => __( 'Login status', 'aegis-user-guard' ), 'grantAccessNote' => __( 'Granting access will remove the pending review hold and allow this Administrator to sign in through the normal WordPress login flow.', 'aegis-user-guard' ), 'forcePasswordResetTitle' => __( 'Force a password reset?', 'aegis-user-guard' ), 'forcePasswordResetMessage' => __( '{{account}} will be required to set a new password before they can use wp-admin again, and their active sessions will be signed out.', 'aegis-user-guard' ), 'forcePasswordResetConfirm' => __( 'Force password reset', 'aegis-user-guard' ), 'signOutEverywhereTitle' => __( 'Sign out everywhere?', 'aegis-user-guard' ), 'signOutEverywhereMessage' => __( 'All active sessions for {{account}} will be ended immediately. They will need to sign in again.', 'aegis-user-guard' ), 'signOutEverywhereConfirm' => __( 'Sign out everywhere', 'aegis-user-guard' ), 'removeIpRuleTitle' => __( 'Remove IP rule?', 'aegis-user-guard' ), 'removeIpRuleMessage' => __( 'The rule for {{rule}} will be removed.', 'aegis-user-guard' ), 'removeRule' => __( 'Remove rule', 'aegis-user-guard' ), 'disableAccountTitle' => __( 'Disable account access?', 'aegis-user-guard' ), 'enableAccountTitle' => __( 'Enable account access?', 'aegis-user-guard' ), 'disableAccountMessage' => __( 'Access for {{account}} will be paused at login. You can restore it later.', 'aegis-user-guard' ), 'enableAccountMessage' => __( 'Access for {{account}} will be restored for future sign-ins.', 'aegis-user-guard' ), 'disableAccess' => __( 'Disable access', 'aegis-user-guard' ), 'enableAccess' => __( 'Enable access', 'aegis-user-guard' ), 'clearActivityTitle' => __( 'Clear activity log?', 'aegis-user-guard' ), 'clearActivityMessage' => __( 'This permanently removes the recorded Aegis security events. This action cannot be undone.', 'aegis-user-guard' ), 'clearLog' => __( 'Clear log', 'aegis-user-guard' ), 'disableTwoFactorTitle' => __( 'Disable two-factor authentication?', 'aegis-user-guard' ), 'disableTwoFactorMessage' => __( 'Your account will only require a password to sign in. Your saved backup codes will be deleted.', 'aegis-user-guard' ), 'disableTwoFactor' => __( 'Disable two-factor', 'aegis-user-guard' ), 'thisAccount' => __( 'this account', 'aegis-user-guard' ), 'thisRule' => __( 'this rule', 'aegis-user-guard' ), /* translators: %d is the number of accounts. */ 'accountOne' => __( '%d account', 'aegis-user-guard' ), /* translators: %d is the number of accounts. */ 'accountMany' => __( '%d accounts', 'aegis-user-guard' ), 'unableCompleteReview' => __( 'Unable to complete this Administrator review action.', 'aegis-user-guard' ), 'loading' => __( 'Loading…', 'aegis-user-guard' ), 'tryAgain' => __( 'Try again', 'aegis-user-guard' ), 'grantAdministratorAccessTitle' => __( 'Grant Administrator access?', 'aegis-user-guard' ), 'grantAdministratorAccessMessage' => __( 'This will allow {{account}} to sign in through WordPress. Confirm that you reviewed this account and expected the change.', 'aegis-user-guard' ), 'grantAccess' => __( 'Grant access', 'aegis-user-guard' ), 'somethingWentWrong' => __( 'Something went wrong. Please try again.', 'aegis-user-guard' ), 'enterSixDigitCode' => __( 'Enter the 6-digit code from your authenticator app.', 'aegis-user-guard' ), 'customizeEmailTemplateIntro' => __( 'Customize the subject and message used for every enabled Administrator alert event.', 'aegis-user-guard' ), 'chooseEmailRecipientsIntro' => __( 'Choose which administrators receive enabled account security alerts.', 'aegis-user-guard' ), 'restoreDefaultTemplateConfirm' => __( 'Restore the default Administrator email template?', 'aegis-user-guard' ), /* translators: %d is the number of selected administrators. */ 'recipientOne' => __( '%d administrator selected', 'aegis-user-guard' ), /* translators: %d is the number of selected administrators. */ 'recipientMany' => __( '%d administrators selected', 'aegis-user-guard' ), 'selectAdministrator' => __( 'Select at least one administrator', 'aegis-user-guard' ), 'selectAdministratorOrAll' => __( 'Select at least one Administrator, or choose All administrators.', 'aegis-user-guard' ), 'unableLoadAdministrators' => __( 'Unable to load Administrator accounts.', 'aegis-user-guard' ), 'noAdministrators' => __( 'No Administrator accounts found.', 'aegis-user-guard' ), 'showingActivity' => __( 'Showing {{start}}–{{end}} of {{total}}', 'aegis-user-guard' ), 'noMatchingEvents' => __( 'No matching events', 'aegis-user-guard' ), 'previous' => __( 'Previous', 'aegis-user-guard' ), 'next' => __( 'Next', 'aegis-user-guard' ), ); } /** * Handle the protect_authentication callback. * * @param mixed $user Parameter passed to the callback. * @param mixed $username Parameter passed to the callback. * @param mixed $password Parameter passed to the callback. */ public static function protect_authentication( $user, $username, $password ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress authenticate filter requires the complete callback signature. if ( $user instanceof WP_Error || ! ( $user instanceof WP_User ) ) { return $user; } $user_id = $user->ID; $settings = self::settings(); if ( $settings['throttle_enabled'] && self::is_ip_locked_out( self::get_client_ip() ) ) { self::log_activity( 'login_blocked', $user_id, array( 'reason' => 'ip_lockout_active' ) ); return new WP_Error( 'aegis_ip_locked', __( 'Too many failed sign-in attempts. Access from this network is temporarily locked. Please try again later.', 'aegis-user-guard' ) ); } if ( $settings['throttle_enabled'] && self::is_user_locked_out( $user_id ) ) { self::log_activity( 'login_blocked', $user_id, array( 'reason' => 'account_lockout_active' ) ); return new WP_Error( 'aegis_login_locked', __( 'Too many failed sign-in attempts. This account is temporarily locked. Please try again later.', 'aegis-user-guard' ) ); } if ( self::is_pending_admin_review( $user_id ) ) { self::log_activity( 'login_blocked', $user_id, array( 'reason' => 'pending_administrator_review' ) ); return new WP_Error( 'aegis_admin_pending_review', __( 'Administrator access pending review. An existing Administrator must grant access before this account can sign in.', 'aegis-user-guard' ) ); } if ( self::is_account_disabled( $user_id ) ) { self::log_activity( 'login_blocked', $user_id, array( 'reason' => 'manual_pause' ) ); return new WP_Error( 'aegis_account_disabled', __( 'Account access paused. Please contact an administrator to reactivate your account.', 'aegis-user-guard' ) ); } if ( $settings['inactivity_enabled'] && self::is_inactive( $user_id, $settings['inactivity_days'] ) ) { self::set_account_disabled( $user_id, true ); self::log_activity( 'login_blocked', $user_id, array( 'reason' => 'inactivity_lockout' ) ); return new WP_Error( 'aegis_account_inactive', __( 'Account access paused. This account has been locked after an extended period of inactivity. Please contact an administrator.', 'aegis-user-guard' ) ); } return $user; } /** * Handle the redirect_expired_password_user callback. * * @param mixed $redirect_to Parameter passed to the callback. * @param mixed $requested_redirect_to Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. */ public static function redirect_expired_password_user( $redirect_to, $requested_redirect_to, $user ) { if ( ! $user instanceof WP_User || ! self::must_reset_password( $user->ID ) ) { return $redirect_to; } $key = get_password_reset_key( $user ); if ( is_wp_error( $key ) ) { return $redirect_to; } return network_site_url( 'wp-login.php?action=rp&key=' . rawurlencode( $key ) . '&login=' . rawurlencode( $user->user_login ), 'login' ); } /** * Handle the guard_expired_admin_session callback. */ public static function guard_expired_admin_session() { if ( ! is_user_logged_in() || ! self::must_reset_password( get_current_user_id() ) ) { return; } global $pagenow; if ( in_array( $pagenow, array( 'profile.php', 'admin-ajax.php', 'async-upload.php' ), true ) || wp_doing_ajax() ) { return; } wp_safe_redirect( admin_url( 'profile.php#aegis-password-expired' ) ); exit; } /** * Handle the render_expired_password_notice callback. */ public static function render_expired_password_notice() { $user_id = get_current_user_id(); if ( ! is_user_logged_in() || ! self::must_reset_password( $user_id ) ) { return; } $forced = (bool) get_user_meta( $user_id, self::META_FORCE_PASSWORD_RESET, true ); printf( '

%s %s

', esc_html__( 'Password update required.', 'aegis-user-guard' ), $forced ? esc_html__( 'An administrator has required a password reset on this account. Update it from this profile screen to restore normal access.', 'aegis-user-guard' ) : esc_html__( 'Your password has reached the configured age limit. Update it from this profile screen to restore normal access.', 'aegis-user-guard' ) ); } /** * Handle the record_password_change callback. * * @param mixed $user Parameter passed to the callback. * @param mixed $new_pass Parameter passed to the callback. */ public static function record_password_change( $user, $new_pass ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress password_reset action supplies the new password for compatibility. if ( $user instanceof WP_User ) { self::record_password_event( $user->ID ); } } /** * Handle the record_password_set callback. * * @param mixed $password Parameter passed to the callback. * @param mixed $user_id Parameter passed to the callback. */ public static function record_password_set( $password, $user_id ) { if ( $user_id ) { self::record_password_event( $user_id ); } } /** * Handle the record_password_event callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function record_password_event( $user_id ) { $user_id = absint( $user_id ); if ( ! $user_id || isset( self::$password_events_recorded[ $user_id ] ) ) { return; } self::$password_events_recorded[ $user_id ] = true; update_user_meta( $user_id, self::META_LAST_PASSWORD, time() ); delete_user_meta( $user_id, self::META_FORCE_PASSWORD_RESET ); self::log_activity( 'password_changed', $user_id ); self::notify_administrators( $user_id, 'password_changed' ); } /** * Handle the handle_profile_update callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $old_user_data Parameter passed to the callback. * @param mixed $userdata Parameter passed to the callback. */ public static function handle_profile_update( $user_id, $old_user_data, $userdata ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress profile_update action supplies the complete hook payload. if ( ! $old_user_data instanceof WP_User ) { return; } $user = get_userdata( $user_id ); if ( ! $user instanceof WP_User ) { return; } if ( $old_user_data->user_login !== $user->user_login ) { self::notify_administrators( $user_id, 'username_changed' ); } if ( strtolower( $old_user_data->user_email ) !== strtolower( $user->user_email ) ) { self::notify_administrators( $user_id, 'email_changed' ); } if ( $old_user_data->user_pass !== $user->user_pass ) { self::record_password_event( $user_id ); } } /** * Handle the record_successful_login callback. * * @param mixed $user_login Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. */ public static function record_successful_login( $user_login, $user ) { if ( ! $user instanceof WP_User ) { return; } $now = time(); update_user_meta( $user->ID, self::META_LAST_LOGIN, $now ); self::log_activity( 'login_success', $user->ID ); if ( ! get_user_meta( $user->ID, self::META_LAST_PASSWORD, true ) ) { update_user_meta( $user->ID, self::META_LAST_PASSWORD, $now ); } delete_user_meta( $user->ID, self::META_LOGIN_ATTEMPTS ); delete_user_meta( $user->ID, self::META_LOGIN_LOCKOUT_UNTIL ); delete_transient( self::TRANSIENT_IP_LOCKOUT . md5( self::get_client_ip() ) ); self::notify_administrators( $user->ID, 'login' ); self::maybe_alert_new_device( $user, self::get_client_ip() ); } /** * Handle the record_failed_login callback. * * @param mixed $username Parameter passed to the callback. * @param mixed $error Parameter passed to the callback. */ public static function record_failed_login( $username, $error = null ) { $settings = self::settings(); $is_aegis_block = $error instanceof WP_Error && 0 === strpos( $error->get_error_code(), 'aegis_' ); $ip = self::get_client_ip(); $user = get_user_by( 'login', $username ); if ( ! $is_aegis_block ) { self::log_activity( 'login_failed', $user instanceof WP_User ? $user->ID : 0, array_filter( array( 'username' => $user instanceof WP_User ? '' : sanitize_text_field( $username ), 'ip' => $ip, ) ) ); } if ( ! $settings['throttle_enabled'] || $is_aegis_block ) { return; } $max_attempts = (int) $settings['throttle_max_attempts']; $lockout_seconds = (int) $settings['throttle_lockout_minutes'] * MINUTE_IN_SECONDS; if ( '' !== $ip && ! self::is_ip_allowlisted( $ip ) ) { $ip_key = self::TRANSIENT_IP_LOCKOUT . md5( $ip ); $ip_state = get_transient( $ip_key ); $ip_state = is_array( $ip_state ) ? $ip_state : array( 'count' => 0 ); $ip_state['count'] = (int) $ip_state['count'] + 1; if ( $ip_state['count'] >= $max_attempts ) { $ip_state['locked_until'] = time() + $lockout_seconds; self::log_activity( 'login_blocked', 0, array( 'reason' => 'ip_lockout_triggered', 'ip' => $ip, ) ); } set_transient( $ip_key, $ip_state, $lockout_seconds ); } if ( $user instanceof WP_User ) { $attempts = (int) get_user_meta( $user->ID, self::META_LOGIN_ATTEMPTS, true ) + 1; update_user_meta( $user->ID, self::META_LOGIN_ATTEMPTS, $attempts ); if ( $attempts >= $max_attempts ) { update_user_meta( $user->ID, self::META_LOGIN_LOCKOUT_UNTIL, time() + $lockout_seconds ); self::log_activity( 'login_blocked', $user->ID, array( 'reason' => 'account_lockout_triggered' ) ); } } } /** * Handle the is_user_locked_out callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function is_user_locked_out( $user_id ) { $locked_until = absint( get_user_meta( $user_id, self::META_LOGIN_LOCKOUT_UNTIL, true ) ); if ( $locked_until && $locked_until <= time() ) { delete_user_meta( $user_id, self::META_LOGIN_LOCKOUT_UNTIL ); delete_user_meta( $user_id, self::META_LOGIN_ATTEMPTS ); return false; } return $locked_until > time(); } /** * Handle the is_ip_locked_out callback. * * @param mixed $ip Parameter passed to the callback. */ private static function is_ip_locked_out( $ip ) { if ( '' === $ip ) { return false; } $state = get_transient( self::TRANSIENT_IP_LOCKOUT . md5( $ip ) ); return is_array( $state ) && ! empty( $state['locked_until'] ) && $state['locked_until'] > time(); } /** * Handle the get_client_ip callback. */ private static function get_client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return apply_filters( 'aegis_client_ip', $ip ); } /** * Handle the handle_user_created callback. * * @param mixed $user_id Parameter passed to the callback. */ public static function handle_user_created( $user_id ) { if ( ! get_user_meta( $user_id, self::META_LAST_PASSWORD, true ) ) { update_user_meta( $user_id, self::META_LAST_PASSWORD, time() ); } $is_administrator = self::user_is_administrator( $user_id ); $creation_context = get_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id ); if ( $is_administrator ) { self::set_pending_admin_review( $user_id, true ); if ( 'awaiting_registration_hook' === $creation_context ) { delete_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id ); } else { set_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id, 'administrator', MINUTE_IN_SECONDS ); } self::log_activity( 'administrator_created', $user_id, array( 'status' => 'pending_review', 'login' => 'blocked', ) ); self::notify_administrators( $user_id, 'created' ); } else { set_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id, 'non_administrator', MINUTE_IN_SECONDS ); self::log_activity( 'user_created', $user_id ); self::notify_administrators( $user_id, 'created' ); } } /** * Handle the handle_role_change callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $role Parameter passed to the callback. * @param mixed $old_roles Parameter passed to the callback. */ public static function handle_role_change( $user_id, $role, $old_roles ) { if ( 'administrator' !== $role || in_array( 'administrator', (array) $old_roles, true ) ) { return; } $creation_context = get_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id ); if ( 'administrator' === $creation_context ) { delete_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id ); return; } if ( 'non_administrator' === $creation_context ) { delete_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id ); } else { $user = get_userdata( $user_id ); $registered = $user && $user->user_registered ? strtotime( $user->user_registered ) : 0; if ( $registered && $registered >= ( time() - 10 ) ) { self::set_pending_admin_review( $user_id, true ); set_transient( self::TRANSIENT_ADMIN_CREATION_GUARD . $user_id, 'awaiting_registration_hook', MINUTE_IN_SECONDS ); return; } } self::set_pending_admin_review( $user_id, true ); self::log_activity( 'administrator_promoted', $user_id, array( 'status' => 'pending_review', 'login' => 'blocked', ) ); self::notify_administrators( $user_id, 'promoted' ); } /** * Handle the render_notification_email callback. * * @param mixed $site_name Parameter passed to the callback. * @param mixed $action_label Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. * @param mixed $actor_name Parameter passed to the callback. * @param mixed $date Parameter passed to the callback. * @param mixed $user_url Parameter passed to the callback. * @param mixed $custom_message Parameter passed to the callback. */ private static function render_notification_email( $site_name, $action_label, $user, $actor_name, $date, $user_url, $custom_message ) { $site_name = esc_html( $site_name ); $action_label = esc_html( $action_label ); $username = esc_html( $user->user_login ); $email = esc_html( $user->user_email ); $actor_name = esc_html( $actor_name ); $date = esc_html( $date ); $user_url = esc_url( $user_url ); $content = wp_kses_post( $custom_message ); if ( false === strpos( $content, '<' ) ) { $content = nl2br( esc_html( $content ) ); } $language = esc_attr( get_bloginfo( 'language' ) ); $security_alert = esc_html__( 'Security alert', 'aegis-user-guard' ); $account_activity = esc_html__( 'Account activity', 'aegis-user-guard' ); /* translators: %s is the site name. */ $detected_event = sprintf( __( 'Aegis detected a user account event on %s. Review the details below to confirm this activity was expected.', 'aegis-user-guard' ), $site_name ); $account_label = esc_html__( 'Account', 'aegis-user-guard' ); $email_label = esc_html__( 'Email', 'aegis-user-guard' ); $occurred_label = esc_html__( 'Occurred', 'aegis-user-guard' ); $initiated_by_label = esc_html__( 'Initiated by', 'aegis-user-guard' ); $review_account = esc_html__( 'Review account securely', 'aegis-user-guard' ); $copy_url = esc_html__( 'If the button does not work, copy and paste this URL into your browser:', 'aegis-user-guard' ); /* translators: %s is the site name. */ $footer_message = sprintf( __( 'This notification was sent by Aegis User Guard for %s. Keep this message for your security records.', 'aegis-user-guard' ), $site_name ); $automated_notice = esc_html__( 'Automated security notification', 'aegis-user-guard' ); return '' . $action_label . ' · ' . $site_name . '

' . $automated_notice . '

'; } /** * Handle the render_notification_plain_text callback. * * @param mixed $site_name Parameter passed to the callback. * @param mixed $action_label Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. * @param mixed $actor_name Parameter passed to the callback. * @param mixed $date Parameter passed to the callback. * @param mixed $user_url Parameter passed to the callback. * @param mixed $custom_message Parameter passed to the callback. */ private static function render_notification_plain_text( $site_name, $action_label, $user, $actor_name, $date, $user_url, $custom_message ) { $content = trim( wp_strip_all_tags( preg_replace( '/<\/(p|div|li|h[1-6])>/i', "\n", $custom_message ) ) ); /* translators: %1$s is a line break; %2$s is the action; %3$s is the site name; %4$s is the username; %5$s is the email; %6$s is the date; %7$s is the actor; %8$s is the message; and %9$s is the account URL. */ $template = __( 'Aegis User Guard security notification%1$s%1$s%2$s on %3$s.%1$s%1$sACCOUNT%1$sUsername: %4$s%1$sEmail: %5$s%1$sOccurred: %6$s%1$sInitiated by: %7$s%1$s%1$s%8$s%1$s%1$sReview account securely:%1$s%9$s%1$s%1$sThis notification was sent by Aegis User Guard.', 'aegis-user-guard' ); return sprintf( $template, "\n", $action_label, $site_name, $user->user_login, $user->user_email, $date, $actor_name, $content, $user_url ); } /** * Handle the notify_administrators callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $event Parameter passed to the callback. */ private static function notify_administrators( $user_id, $event ) { $settings = self::settings(); if ( ! $settings['notify_enabled'] ) { return; } $event_settings = array( 'created' => 'notify_user_created', 'promoted' => 'notify_user_created', 'login' => 'notify_user_login', 'username_changed' => 'notify_username_changed', 'email_changed' => 'notify_email_changed', 'password_changed' => 'notify_password_changed', ); if ( empty( $event_settings[ $event ] ) || empty( $settings[ $event_settings[ $event ] ] ) ) { return; } $user = get_userdata( $user_id ); if ( ! $user ) { return; } $recipients = array(); if ( 'selected' === ( $settings['notify_recipients'] ?? 'all' ) ) { $selected_ids = array_filter( array_map( 'absint', (array) ( $settings['notify_recipient_ids'] ?? array() ) ), static function ( $id ) { return $id > 0; } ); foreach ( $selected_ids as $admin_id ) { $admin = get_userdata( $admin_id ); if ( $admin && in_array( 'administrator', (array) $admin->roles, true ) && ! empty( $admin->user_email ) && ( ! in_array( $event, array( 'created', 'promoted' ), true ) || strtolower( $admin->user_email ) !== strtolower( $user->user_email ) ) ) { $recipients[] = $admin->user_email; } } } else { foreach ( get_users( array( 'role' => 'administrator', 'fields' => array( 'user_email' ), ) ) as $admin ) { if ( ! empty( $admin->user_email ) && ( ! in_array( $event, array( 'created', 'promoted' ), true ) || strtolower( $admin->user_email ) !== strtolower( $user->user_email ) ) ) { $recipients[] = $admin->user_email; } } } $recipients = array_unique( $recipients ); if ( empty( $recipients ) ) { return; } $site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); $event_labels = array( 'created' => __( 'New user account created', 'aegis-user-guard' ), 'promoted' => __( 'User promoted to Administrator · pending review', 'aegis-user-guard' ), 'login' => __( 'User signed in', 'aegis-user-guard' ), 'username_changed' => __( 'Username changed', 'aegis-user-guard' ), 'email_changed' => __( 'Email address changed', 'aegis-user-guard' ), 'password_changed' => __( 'Password changed', 'aegis-user-guard' ), ); $action_label = $event_labels[ $event ]; $actor = wp_get_current_user(); $actor_name = 'login' === $event ? $user->display_name : ( $actor instanceof WP_User && $actor->exists() ? $actor->display_name : __( 'System', 'aegis-user-guard' ) ); $date = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); $user_url = admin_url( 'user-edit.php?user_id=' . $user_id ); $values = array( '{{site_name}}' => $site_name, '{{event}}' => $action_label, '{{username}}' => $user->user_login, '{{email}}' => $user->user_email, '{{date}}' => $date, '{{actor}}' => $actor_name, '{{user_url}}' => $user_url, ); $subject = sanitize_text_field( strtr( $settings['email_subject'], $values ) ); $custom_message = strtr( $settings['email_body'], $values ); if ( '' === trim( wp_strip_all_tags( $custom_message ) ) ) { $custom_message = __( 'Please review this account and confirm that the change was expected.', 'aegis-user-guard' ); } $message = self::render_notification_email( $site_name, $action_label, $user, $actor_name, $date, $user_url, $custom_message ); $headers = array( 'Content-Type: text/html; charset=UTF-8' ); $sent = wp_mail( $recipients, $subject, $message, $headers ); self::log_activity( 'notification_sent', $user_id, array( 'event' => $action_label, 'recipient_mode' => 'selected' === ( $settings['notify_recipients'] ?? 'all' ) ? 'selected' : 'all', 'recipients' => count( $recipients ), 'delivery' => $sent ? 'accepted' : 'failed', ) ); } /** * Handle the add_user_status_column callback. * * @param mixed $columns Parameter passed to the callback. */ public static function add_user_status_column( $columns ) { $columns['aegis_security_status'] = __( 'Security status', 'aegis-user-guard' ); return $columns; } /** * Handle the render_user_status_column callback. * * @param mixed $output Parameter passed to the callback. * @param mixed $column_name Parameter passed to the callback. * @param mixed $user_id Parameter passed to the callback. */ public static function render_user_status_column( $output, $column_name, $user_id ) { if ( 'aegis_security_status' !== $column_name ) { return $output; } if ( self::is_pending_admin_review( $user_id ) ) { return '' . esc_html__( 'Pending review', 'aegis-user-guard' ) . ''; } if ( self::is_account_disabled( $user_id ) ) { return '' . esc_html__( 'Access paused', 'aegis-user-guard' ) . ''; } if ( self::settings()['password_enabled'] && self::is_password_expired( $user_id, self::settings()['password_days'] ) ) { return '' . esc_html__( 'Password due', 'aegis-user-guard' ) . ''; } return '' . esc_html__( 'Protected', 'aegis-user-guard' ) . ''; } /** * Handle the add_user_row_action callback. * * @param mixed $actions Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. */ public static function add_user_row_action( $actions, $user ) { if ( self::is_pending_admin_review( $user->ID ) ) { return $actions; } if ( ! current_user_can( 'edit_user', $user->ID ) || ! current_user_can( 'manage_options' ) || get_current_user_id() === $user->ID ) { return $actions; } $disabled = self::is_account_disabled( $user->ID ); $url = wp_nonce_url( admin_url( 'admin-post.php?action=aegis_toggle_user&user_id=' . $user->ID ), self::NONCE ); $actions['aegis_toggle'] = sprintf( '%2$s', esc_url( $url ), esc_html( $disabled ? __( 'Reactivate access', 'aegis-user-guard' ) : __( 'Pause access', 'aegis-user-guard' ) ), $disabled ? 0 : 1 ); $reset_url = wp_nonce_url( admin_url( 'admin-post.php?action=aegis_force_password_reset&user_id=' . $user->ID ), self::NONCE ); $actions['aegis_reset'] = sprintf( '%2$s', esc_url( $reset_url ), esc_html__( 'Force password reset', 'aegis-user-guard' ) ); $sessions_url = wp_nonce_url( admin_url( 'admin-post.php?action=aegis_revoke_sessions&user_id=' . $user->ID ), self::NONCE ); $actions['aegis_sessions'] = sprintf( '%2$s', esc_url( $sessions_url ), esc_html__( 'Sign out everywhere', 'aegis-user-guard' ) ); return $actions; } /** * Handle the toggle_user_status callback. */ public static function toggle_user_status() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to change this account.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; $return_to = isset( $_GET['return_to'] ) ? sanitize_key( wp_unslash( $_GET['return_to'] ) ) : 'users'; if ( ! current_user_can( 'edit_user', $user_id ) || ! $user_id || get_current_user_id() === $user_id ) { wp_die( esc_html__( 'You are not allowed to change this account.', 'aegis-user-guard' ), 403 ); } if ( self::is_pending_admin_review( $user_id ) ) { $base_url = 'settings' === $return_to ? add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) : admin_url( 'users.php' ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'pending_review', $base_url ) ); exit; } $new_disabled = ! self::is_account_disabled( $user_id ); if ( $new_disabled && in_array( 'administrator', (array) get_userdata( $user_id )->roles, true ) ) { $enabled_admins = 0; foreach ( get_users( array( 'role' => 'administrator', 'fields' => 'ID', ) ) as $administrator_id ) { if ( ! self::is_account_disabled( $administrator_id ) ) { ++$enabled_admins; } } if ( $enabled_admins <= 1 ) { $base_url = 'settings' === $return_to ? add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) : admin_url( 'users.php' ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'last_admin', $base_url ) ); exit; } } self::set_account_disabled( $user_id, $new_disabled ); $context = array( 'source' => 'administrator_directory' ); if ( $new_disabled ) { $context['sessions'] = 'terminated'; } self::log_activity( $new_disabled ? 'access_paused' : 'access_reactivated', $user_id, $context ); $base_url = 'settings' === $return_to ? add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) : admin_url( 'users.php' ); $redirect = add_query_arg( array( 'aegis_notice' => self::is_account_disabled( $user_id ) ? 'paused' : 'reactivated' ), $base_url ); wp_safe_redirect( $redirect ); exit; } /** * Handle the administrator_directory_row callback. * * @param mixed $administrator Parameter passed to the callback. */ private static function administrator_directory_row( $administrator ) { if ( ! $administrator instanceof WP_User ) { return ''; } $display_name = $administrator->display_name ? $administrator->display_name : $administrator->user_login; $placeholder_url = plugin_dir_url( AEGIS_USER_GUARD_FILE ) . 'assets/avatar-placeholder.svg'; $avatar_data = get_avatar_data( $administrator->ID, array( 'size' => 48, 'default' => '404', /* translators: %s is the Administrator display name. */ 'alt' => sprintf( __( 'Profile image for %s', 'aegis-user-guard' ), $display_name ), ) ); $avatar_url = ! empty( $avatar_data['url'] ) ? $avatar_data['url'] : $placeholder_url; /* translators: %s is the Administrator display name. */ $avatar_alt = ! empty( $avatar_data['alt'] ) ? $avatar_data['alt'] : sprintf( __( 'Profile image for %s', 'aegis-user-guard' ), $display_name ); /* translators: %s is the Administrator display name. */ $avatar_fallback_alt = sprintf( __( 'Placeholder profile image for %s', 'aegis-user-guard' ), $display_name ); $is_paused = self::is_account_disabled( $administrator->ID ); $is_pending = self::is_pending_admin_review( $administrator->ID ); $is_current = get_current_user_id() === $administrator->ID; $last_login = absint( get_user_meta( $administrator->ID, self::META_LAST_LOGIN, true ) ); $session_count = self::get_user_session_count( $administrator->ID ); $toggle_url = wp_nonce_url( add_query_arg( array( 'action' => 'aegis_toggle_user', 'user_id' => $administrator->ID, 'return_to' => 'settings', ), admin_url( 'admin-post.php' ) ), self::NONCE ); $reset_url = wp_nonce_url( add_query_arg( array( 'action' => 'aegis_force_password_reset', 'user_id' => $administrator->ID, 'return_to' => 'settings', ), admin_url( 'admin-post.php' ) ), self::NONCE ); $sessions_url = wp_nonce_url( add_query_arg( array( 'action' => 'aegis_revoke_sessions', 'user_id' => $administrator->ID, 'return_to' => 'settings', ), admin_url( 'admin-post.php' ) ), self::NONCE ); ob_start(); ?>
<?php echo esc_attr( $avatar_alt ); ?>@user_login ); ?>
user_email ); ?>
display_name ? $administrator->display_name : $administrator->user_login; $registered = $administrator->user_registered ? strtotime( $administrator->user_registered ) : 0; /* translators: %s is the account creation date. */ $created_label = $registered ? sprintf( __( 'Created %s', 'aegis-user-guard' ), wp_date( get_option( 'date_format' ), $registered ) ) : __( 'Creation date not recorded', 'aegis-user-guard' ); ob_start(); ?>
user_login, 0, 1 ) ) ); ?>@user_login ); ?> · user_email ); ?>
array( 'ID', 'user_login', 'user_email' ), 'number' => 4, 'orderby' => 'registered', 'order' => 'DESC', ) ); $counts = count_users(); $total_users = isset( $counts['total_users'] ) ? (int) $counts['total_users'] : 0; $paused_users = self::count_paused_users(); $health = $total_users > 0 ? max( 0, round( ( ( $total_users - $paused_users ) / $total_users ) * 100 ) ) : 100; $activity = self::get_activity_log(); $administrators = get_users( array( 'role' => 'administrator', 'orderby' => 'display_name', 'order' => 'ASC', ) ); $pending_administrators = self::get_pending_administrators(); $directory_per_page = 10; $directory_query = new WP_User_Query( array( 'role' => 'administrator', 'number' => $directory_per_page, 'paged' => 1, 'orderby' => 'display_name', 'order' => 'ASC', 'count_total' => true, ) ); $directory_users = (array) $directory_query->get_results(); $directory_total = (int) $directory_query->get_total(); $directory_total_pages = max( 1, (int) ceil( $directory_total / $directory_per_page ) ); $email_tokens = array( '{{site_name}}' => __( "Your site's name", 'aegis-user-guard' ), '{{event}}' => __( 'The account event that triggered this alert', 'aegis-user-guard' ), '{{username}}' => __( "The account's username", 'aegis-user-guard' ), '{{email}}' => __( "The account's email address", 'aegis-user-guard' ), '{{date}}' => __( 'When the change happened', 'aegis-user-guard' ), '{{actor}}' => __( 'Who made the change', 'aegis-user-guard' ), '{{user_url}}' => __( "Link to the account's profile", 'aegis-user-guard' ), ); $ip_rules = self::get_ip_rules(); $checklist = self::security_checklist(); $checklist_passed = count( array_filter( $checklist, static function ( $item ) { return true === $item['status']; } ) ); $checklist_score = count( $checklist ) ? (int) round( ( $checklist_passed / count( $checklist ) ) * 100 ) : 100; /* translators: %1$s is the active account count and %2$s is the total account count. */ $health_title = sprintf( __( '%1$s of %2$s accounts currently have active login access (not manually or automatically paused).', 'aegis-user-guard' ), number_format_i18n( $total_users - $paused_users ), number_format_i18n( $total_users ) ); /* translators: %s is the number of pending administrators. */ $pending_count_label = sprintf( _n( '%s account', '%s accounts', count( $pending_administrators ), 'aegis-user-guard' ), number_format_i18n( count( $pending_administrators ) ) ); /* translators: %1$s is the first result, %2$s is the last result, and %3$s is the total. */ $directory_count_label = sprintf( __( 'Showing %1$s–%2$s of %3$s', 'aegis-user-guard' ), $directory_total ? 1 : 0, min( $directory_per_page, $directory_total ), $directory_total ); /* translators: %d is the percentage of checks passing. */ $checklist_score_label = sprintf( __( '%d%% of checks passing', 'aegis-user-guard' ), $checklist_score ); ?>
Aegis User Guard

%

__( 'Activity log cleared.', 'aegis-user-guard' ), 'last_admin' => __( 'At least one Administrator account must remain enabled.', 'aegis-user-guard' ), 'pending_review' => __( 'This Administrator is awaiting review. Grant access from the Pending Administrator access section.', 'aegis-user-guard' ), 'password_reset_forced' => __( 'A password reset was required for this account and its sessions were signed out.', 'aegis-user-guard' ), 'sessions_revoked' => __( 'All sessions for this account were signed out.', 'aegis-user-guard' ), 'ip_added' => __( 'IP rule saved.', 'aegis-user-guard' ), 'ip_removed' => __( 'IP rule removed.', 'aegis-user-guard' ), 'ip_invalid' => __( 'Enter a valid IP address.', 'aegis-user-guard' ), ); $aegis_notice_kind = 'ip_invalid' === $aegis_notice || 'last_admin' === $aegis_notice ? 'notice-warning' : 'notice-success'; ?>

wp/v2/users

= ( absint( $days ) * DAY_IN_SECONDS ); } /** * Handle the is_inactive callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $days Parameter passed to the callback. */ private static function is_inactive( $user_id, $days ) { $last_login = absint( get_user_meta( $user_id, self::META_LAST_LOGIN, true ) ); if ( ! $last_login ) { $last_login = absint( get_user_meta( $user_id, self::META_LAST_PASSWORD, true ) ); } return $last_login && ( time() - $last_login ) >= ( absint( $days ) * DAY_IN_SECONDS ); } /** * Handle the is_account_disabled callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function is_account_disabled( $user_id ) { return (bool) get_user_meta( $user_id, self::META_DISABLED, true ); } /** * Handle the set_account_disabled callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $disabled Parameter passed to the callback. */ private static function set_account_disabled( $user_id, $disabled ) { if ( $disabled ) { update_user_meta( $user_id, self::META_DISABLED, 1 ); self::destroy_user_sessions( $user_id ); } else { delete_user_meta( $user_id, self::META_DISABLED ); update_user_meta( $user_id, self::META_LAST_LOGIN, time() ); } } /** * Handle the destroy_user_sessions callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function destroy_user_sessions( $user_id ) { if ( ! class_exists( 'WP_Session_Tokens' ) ) { return; } WP_Session_Tokens::get_instance( $user_id )->destroy_all(); } /** * Handle the user_is_administrator callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function user_is_administrator( $user_id ) { $user = get_userdata( $user_id ); return $user && in_array( 'administrator', (array) $user->roles, true ); } /** * Handle the count_paused_users callback. */ private static function count_paused_users() { return count( get_users( array( 'fields' => 'ID', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The paused-account policy intentionally queries user metadata. 'meta_query' => array( array( 'key' => self::META_DISABLED, 'value' => 1, 'compare' => '=', ), ), ) ) ); } /** * Handle the base32_encode callback. * * @param mixed $data Parameter passed to the callback. */ private static function base32_encode( $data ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; if ( '' === $data ) { return ''; } $binary = ''; foreach ( str_split( $data ) as $char ) { $binary .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT ); } $output = ''; foreach ( str_split( $binary, 5 ) as $chunk ) { $chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT ); $output .= $alphabet[ bindec( $chunk ) ]; } return $output; } /** * Handle the base32_decode callback. * * @param mixed $secret Parameter passed to the callback. */ private static function base32_decode( $secret ) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $secret = strtoupper( preg_replace( '/[^A-Za-z2-7]/', '', (string) $secret ) ); if ( '' === $secret ) { return ''; } $binary = ''; foreach ( str_split( $secret ) as $char ) { $pos = strpos( $alphabet, $char ); if ( false === $pos ) { continue; } $binary .= str_pad( decbin( $pos ), 5, '0', STR_PAD_LEFT ); } $bytes = ''; foreach ( str_split( $binary, 8 ) as $chunk ) { if ( 8 !== strlen( $chunk ) ) { continue; } $bytes .= chr( bindec( $chunk ) ); } return $bytes; } /** * Handle the generate_totp_secret callback. */ private static function generate_totp_secret() { return self::base32_encode( random_bytes( 20 ) ); } /** * Handle the totp_code callback. * * @param mixed $secret Parameter passed to the callback. * @param mixed $timeslice Parameter passed to the callback. */ private static function totp_code( $secret, $timeslice ) { $key = self::base32_decode( $secret ); if ( '' === $key ) { return false; } $binary_time = pack( 'N', 0 ) . pack( 'N', $timeslice ); $hash = hash_hmac( 'sha1', $binary_time, $key, true ); $offset = ord( substr( $hash, -1 ) ) & 0x0F; $truncated = ( ( ord( $hash[ $offset ] ) & 0x7F ) << 24 ) | ( ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16 ) | ( ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8 ) | ( ord( $hash[ $offset + 3 ] ) & 0xFF ); return str_pad( (string) ( $truncated % 1000000 ), 6, '0', STR_PAD_LEFT ); } /** * Handle the verify_totp_code callback. * * @param mixed $secret Parameter passed to the callback. * @param mixed $code Parameter passed to the callback. */ private static function verify_totp_code( $secret, $code ) { $code = preg_replace( '/[^0-9]/', '', (string) $code ); if ( 6 !== strlen( $code ) ) { return false; } $current_slice = (int) floor( time() / 30 ); for ( $offset = -1; $offset <= 1; $offset++ ) { $candidate = self::totp_code( $secret, $current_slice + $offset ); if ( false !== $candidate && hash_equals( $candidate, $code ) ) { return true; } } return false; } /** * Handle the generate_backup_codes callback. */ private static function generate_backup_codes() { $codes = array(); for ( $i = 0; $i < 8; $i++ ) { $codes[] = wp_generate_password( 4, false, false ) . '-' . wp_generate_password( 4, false, false ); } return $codes; } /** * Handle the hash_backup_code callback. * * @param mixed $code Parameter passed to the callback. */ private static function hash_backup_code( $code ) { return hash_hmac( 'sha256', strtoupper( trim( (string) $code ) ), wp_salt( 'secure_auth' ) ); } /** * Handle the user_has_2fa callback. * * @param mixed $user_id Parameter passed to the callback. */ private static function user_has_2fa( $user_id ) { return (bool) get_user_meta( $user_id, self::META_2FA_ENABLED, true ) && '' !== (string) get_user_meta( $user_id, self::META_2FA_SECRET, true ); } /** * Handle the verify_2fa_code callback. * * @param mixed $user_id Parameter passed to the callback. * @param mixed $code Parameter passed to the callback. */ private static function verify_2fa_code( $user_id, $code ) { $secret = get_user_meta( $user_id, self::META_2FA_SECRET, true ); if ( $secret && self::verify_totp_code( $secret, $code ) ) { return true; } $backup_codes = get_user_meta( $user_id, self::META_2FA_BACKUP_CODES, true ); $backup_codes = is_array( $backup_codes ) ? $backup_codes : array(); $hash = self::hash_backup_code( $code ); foreach ( $backup_codes as $index => $stored_hash ) { if ( hash_equals( $stored_hash, $hash ) ) { unset( $backup_codes[ $index ] ); update_user_meta( $user_id, self::META_2FA_BACKUP_CODES, array_values( $backup_codes ) ); self::log_activity( '2fa_backup_code_used', $user_id ); return true; } } return false; } /** * Handle the enforce_two_factor callback. * * @param mixed $user Parameter passed to the callback. * @param mixed $username Parameter passed to the callback. * @param mixed $password Parameter passed to the callback. */ public static function enforce_two_factor( $user, $username, $password ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress authenticate filter requires the complete callback signature. if ( ! ( $user instanceof WP_User ) || ! self::user_has_2fa( $user->ID ) ) { return $user; } $token = wp_generate_password( 32, false, false ); set_transient( self::TRANSIENT_2FA_LOGIN . $token, array( 'user_id' => $user->ID, // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This value is read from the native WordPress login request. 'remember' => ! empty( $_POST['rememberme'] ), ), 5 * MINUTE_IN_SECONDS ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This value is read from the native WordPress login redirect request. $redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(); $url = add_query_arg( array( 'action' => 'aegis_2fa', 'aegis_token' => $token, 'redirect_to' => rawurlencode( $redirect_to ), ), wp_login_url() ); wp_safe_redirect( $url ); exit; } /** * Handle the render_2fa_login_form callback. */ public static function render_2fa_login_form() { $token = isset( $_REQUEST['aegis_token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['aegis_token'] ) ) : ''; $redirect_to = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : admin_url(); $state = $token ? get_transient( self::TRANSIENT_2FA_LOGIN . $token ) : false; if ( ! is_array( $state ) || empty( $state['user_id'] ) ) { wp_safe_redirect( wp_login_url() ); exit; } $user = get_userdata( absint( $state['user_id'] ) ); if ( ! $user ) { delete_transient( self::TRANSIENT_2FA_LOGIN . $token ); wp_safe_redirect( wp_login_url() ); exit; } $error = ''; if ( isset( $_POST['aegis_2fa_code'] ) ) { check_admin_referer( 'aegis_2fa_verify_' . $token, 'aegis_2fa_nonce' ); if ( self::is_ip_locked_out( self::get_client_ip() ) || self::is_user_locked_out( $user->ID ) ) { $error = __( 'Too many failed attempts. Please try again later.', 'aegis-user-guard' ); } else { $code = sanitize_text_field( wp_unslash( $_POST['aegis_2fa_code'] ) ); if ( self::verify_2fa_code( $user->ID, $code ) ) { delete_transient( self::TRANSIENT_2FA_LOGIN . $token ); wp_set_current_user( $user->ID ); wp_set_auth_cookie( $user->ID, ! empty( $state['remember'] ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Replays the native WordPress login action after successful Aegis 2FA. do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applies the native WordPress login redirect after successful Aegis 2FA. wp_safe_redirect( apply_filters( 'login_redirect', $redirect_to, $redirect_to, $user ) ); exit; } self::record_failed_login( $user->user_login, null ); self::log_activity( 'login_blocked', $user->ID, array( 'reason' => 'invalid_2fa_code' ) ); $error = __( 'That code was not correct. Please try again.', 'aegis-user-guard' ); } } login_header( __( 'Two-Factor Authentication', 'aegis-user-guard' ) ); ?>

ID ) { return; } $enabled = self::user_has_2fa( $user->ID ); $pending_secret = get_user_meta( $user->ID, self::META_2FA_PENDING_SECRET, true ); $reveal_key = self::TRANSIENT_2FA_REVEAL . $user->ID; $reveal_codes = get_transient( $reveal_key ); if ( $reveal_codes ) { delete_transient( $reveal_key ); } $issuer = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); ?>

__( 'You must be logged in.', 'aegis-user-guard' ) ), 403 ); } check_ajax_referer( 'aegis_user_guard_2fa', 'nonce' ); $user_id = get_current_user_id(); update_user_meta( $user_id, self::META_2FA_PENDING_SECRET, self::generate_totp_secret() ); wp_send_json_success(); } /** * Handle the ajax_2fa_confirm_setup callback. */ public static function ajax_2fa_confirm_setup() { if ( ! is_user_logged_in() ) { wp_send_json_error( array( 'message' => __( 'You must be logged in.', 'aegis-user-guard' ) ), 403 ); } check_ajax_referer( 'aegis_user_guard_2fa', 'nonce' ); $user_id = get_current_user_id(); $secret = get_user_meta( $user_id, self::META_2FA_PENDING_SECRET, true ); $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; if ( ! $secret || ! self::verify_totp_code( $secret, $code ) ) { wp_send_json_error( array( 'message' => __( 'That code was not correct. Please try again.', 'aegis-user-guard' ) ), 400 ); } update_user_meta( $user_id, self::META_2FA_SECRET, $secret ); update_user_meta( $user_id, self::META_2FA_ENABLED, 1 ); delete_user_meta( $user_id, self::META_2FA_PENDING_SECRET ); $codes = self::generate_backup_codes(); update_user_meta( $user_id, self::META_2FA_BACKUP_CODES, array_map( array( __CLASS__, 'hash_backup_code' ), $codes ) ); set_transient( self::TRANSIENT_2FA_REVEAL . $user_id, $codes, MINUTE_IN_SECONDS ); self::log_activity( '2fa_enabled', $user_id ); wp_send_json_success(); } /** * Handle the handle_2fa_disable callback. */ public static function handle_2fa_disable() { if ( ! is_user_logged_in() ) { wp_die( esc_html__( 'You must be logged in.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $user_id = get_current_user_id(); delete_user_meta( $user_id, self::META_2FA_SECRET ); delete_user_meta( $user_id, self::META_2FA_ENABLED ); delete_user_meta( $user_id, self::META_2FA_PENDING_SECRET ); delete_user_meta( $user_id, self::META_2FA_BACKUP_CODES ); self::log_activity( '2fa_disabled', $user_id ); wp_safe_redirect( admin_url( 'profile.php#aegis-2fa' ) ); exit; } /** * Handle the handle_2fa_regenerate_codes callback. */ public static function handle_2fa_regenerate_codes() { if ( ! is_user_logged_in() ) { wp_die( esc_html__( 'You must be logged in.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $user_id = get_current_user_id(); if ( ! self::user_has_2fa( $user_id ) ) { wp_safe_redirect( admin_url( 'profile.php#aegis-2fa' ) ); exit; } $codes = self::generate_backup_codes(); update_user_meta( $user_id, self::META_2FA_BACKUP_CODES, array_map( array( __CLASS__, 'hash_backup_code' ), $codes ) ); set_transient( self::TRANSIENT_2FA_REVEAL . $user_id, $codes, MINUTE_IN_SECONDS ); self::log_activity( '2fa_backup_codes_regenerated', $user_id ); wp_safe_redirect( admin_url( 'profile.php#aegis-2fa' ) ); exit; } /** * Handle the render_2fa_required_notice callback. */ public static function render_2fa_required_notice() { if ( ! is_user_logged_in() || ! self::settings()['twofa_required'] ) { return; } $user_id = get_current_user_id(); if ( ! self::user_is_administrator( $user_id ) || self::user_has_2fa( $user_id ) ) { return; } printf( '

%s %s

', esc_html__( 'Two-factor authentication required.', 'aegis-user-guard' ), esc_html__( 'Set up an authenticator app from your profile to continue using wp-admin.', 'aegis-user-guard' ) ); } /* * IP access rules (manual block / allow list). */ /** * Return configured IP access rules. * * @return array Configured access rules. */ private static function get_ip_rules() { $rules = get_option( self::OPTION_IP_RULES, array() ); return is_array( $rules ) ? $rules : array(); } /** * Handle the find_ip_rule callback. * * @param mixed $ip Parameter passed to the callback. */ private static function find_ip_rule( $ip ) { foreach ( self::get_ip_rules() as $rule ) { if ( isset( $rule['ip'] ) && $rule['ip'] === $ip ) { return $rule; } } return null; } /** * Handle the is_ip_blocked callback. * * @param mixed $ip Parameter passed to the callback. */ private static function is_ip_blocked( $ip ) { $rule = self::find_ip_rule( $ip ); return $rule && 'block' === $rule['mode']; } /** * Handle the is_ip_allowlisted callback. * * @param mixed $ip Parameter passed to the callback. */ private static function is_ip_allowlisted( $ip ) { $rule = self::find_ip_rule( $ip ); return $rule && 'allow' === $rule['mode']; } /** * Handle the block_listed_ip callback. * * @param mixed $user Parameter passed to the callback. * @param mixed $username Parameter passed to the callback. * @param mixed $password Parameter passed to the callback. */ public static function block_listed_ip( $user, $username, $password ) { if ( '' === $username && '' === $password ) { return $user; } $ip = self::get_client_ip(); if ( '' !== $ip && self::is_ip_blocked( $ip ) ) { self::log_activity( 'login_blocked', 0, array( 'reason' => 'ip_blocklist', 'ip' => $ip, ) ); return new WP_Error( 'aegis_ip_blocked', __( 'Access denied. Sign-in from this network is not permitted.', 'aegis-user-guard' ) ); } return $user; } /** * Handle the handle_add_ip_rule callback. */ public static function handle_add_ip_rule() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage IP rules.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : ''; $mode = isset( $_POST['mode'] ) && 'allow' === $_POST['mode'] ? 'allow' : 'block'; $note = isset( $_POST['note'] ) ? sanitize_text_field( wp_unslash( $_POST['note'] ) ) : ''; $redirect = add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) . '#aegis-tab-access'; if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { wp_safe_redirect( add_query_arg( 'aegis_notice', 'ip_invalid', $redirect ) ); exit; } $rules = self::get_ip_rules(); $rules = array_values( array_filter( $rules, static function ( $rule ) use ( $ip ) { return ! isset( $rule['ip'] ) || $rule['ip'] !== $ip; } ) ); array_unshift( $rules, array( 'ip' => $ip, 'mode' => $mode, 'note' => $note, 'added' => time(), 'added_by' => get_current_user_id(), ) ); update_option( self::OPTION_IP_RULES, array_slice( $rules, 0, self::MAX_IP_RULES ), false ); self::log_activity( 'ip_rule_added', 0, array( 'ip' => $ip, 'mode' => $mode, ) ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'ip_added', $redirect ) ); exit; } /** * Handle the handle_remove_ip_rule callback. */ public static function handle_remove_ip_rule() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage IP rules.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $ip = isset( $_GET['ip'] ) ? sanitize_text_field( wp_unslash( $_GET['ip'] ) ) : ''; $rules = array_values( array_filter( self::get_ip_rules(), static function ( $rule ) use ( $ip ) { return ! isset( $rule['ip'] ) || $rule['ip'] !== $ip; } ) ); update_option( self::OPTION_IP_RULES, $rules, false ); self::log_activity( 'ip_rule_removed', 0, array( 'ip' => $ip ) ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'ip_removed', add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) . '#aegis-tab-access' ) ); exit; } /* * Password complexity policy. */ /** * Return password complexity validation errors. * * @param mixed $password Password to validate. * @return array Validation errors. */ private static function password_complexity_errors( $password ) { $settings = self::settings(); if ( empty( $settings['complexity_enabled'] ) || '' === $password ) { return array(); } $errors = array(); $min_length = (int) $settings['complexity_min_length']; if ( strlen( $password ) < $min_length ) { /* translators: %d is the configured minimum password length. */ $errors[] = sprintf( __( 'Password must be at least %d characters long.', 'aegis-user-guard' ), $min_length ); } if ( ! empty( $settings['complexity_require_mixed_case'] ) && ! ( preg_match( '/[a-z]/', $password ) && preg_match( '/[A-Z]/', $password ) ) ) { $errors[] = __( 'Password must include both uppercase and lowercase letters.', 'aegis-user-guard' ); } if ( ! empty( $settings['complexity_require_number'] ) && ! preg_match( '/[0-9]/', $password ) ) { $errors[] = __( 'Password must include at least one number.', 'aegis-user-guard' ); } if ( ! empty( $settings['complexity_require_symbol'] ) && ! preg_match( '/[^a-zA-Z0-9]/', $password ) ) { $errors[] = __( 'Password must include at least one symbol.', 'aegis-user-guard' ); } return $errors; } /** * Handle the enforce_password_complexity_reset callback. * * @param mixed $errors Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. */ public static function enforce_password_complexity_reset( $errors, $user ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress hook supplies the user object for compatibility. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WordPress validates the native password-reset form nonce before this hook runs. if ( ! isset( $_POST['pass1'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passwords must be preserved exactly; WordPress validates the native form before this hook. $password = wp_unslash( $_POST['pass1'] ); foreach ( self::password_complexity_errors( $password ) as $message ) { $errors->add( 'aegis_pass_complexity', $message ); } } /** * Handle the enforce_password_complexity_profile callback. * * @param mixed $errors Parameter passed to the callback. * @param mixed $update Parameter passed to the callback. * @param mixed $user Parameter passed to the callback. */ public static function enforce_password_complexity_profile( $errors, $update, $user ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress hook supplies the complete profile update payload. if ( empty( $user->user_pass ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WordPress validates the native profile form nonce before this hook runs. if ( ! isset( $_POST['pass1'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passwords must be preserved exactly; WordPress validates the native form before this hook. $password = wp_unslash( $_POST['pass1'] ); if ( '' === $password ) { return; } foreach ( self::password_complexity_errors( $password ) as $message ) { $errors->add( 'aegis_pass_complexity', $message ); } } /* * Forced password reset. */ /** * Determine whether a user must reset their password. * * @param mixed $user_id User ID. * @return bool Whether a reset is required. */ private static function must_reset_password( $user_id ) { if ( get_user_meta( $user_id, self::META_FORCE_PASSWORD_RESET, true ) ) { return true; } $settings = self::settings(); return (bool) $settings['password_enabled'] && self::is_password_expired( $user_id, $settings['password_days'] ); } /** * Handle the handle_force_password_reset callback. */ public static function handle_force_password_reset() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to change this account.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; $return_to = isset( $_GET['return_to'] ) ? sanitize_key( wp_unslash( $_GET['return_to'] ) ) : 'users'; if ( ! current_user_can( 'edit_user', $user_id ) || ! $user_id || get_current_user_id() === $user_id ) { wp_die( esc_html__( 'You are not allowed to change this account.', 'aegis-user-guard' ), 403 ); } update_user_meta( $user_id, self::META_FORCE_PASSWORD_RESET, 1 ); self::destroy_user_sessions( $user_id ); self::log_activity( 'password_reset_forced', $user_id, array( 'sessions' => 'terminated' ) ); $base_url = 'settings' === $return_to ? add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) . '#aegis-tab-directory' : admin_url( 'users.php' ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'password_reset_forced', $base_url ) ); exit; } /* * Active sessions. */ /** * Count active sessions for a user. * * @param mixed $user_id User ID. * @return int Active session count. */ private static function get_user_session_count( $user_id ) { if ( ! class_exists( 'WP_Session_Tokens' ) ) { return 0; } return count( WP_Session_Tokens::get_instance( $user_id )->get_all() ); } /** * Handle the handle_revoke_sessions callback. */ public static function handle_revoke_sessions() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to change this account.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; $return_to = isset( $_GET['return_to'] ) ? sanitize_key( wp_unslash( $_GET['return_to'] ) ) : 'users'; if ( ! current_user_can( 'edit_user', $user_id ) || ! $user_id ) { wp_die( esc_html__( 'You are not allowed to change this account.', 'aegis-user-guard' ), 403 ); } self::destroy_user_sessions( $user_id ); self::log_activity( 'sessions_revoked', $user_id, array( 'source' => get_current_user_id() === $user_id ? 'self' : 'administrator_directory' ) ); $base_url = 'settings' === $return_to ? add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) ) . '#aegis-tab-directory' : admin_url( 'users.php' ); wp_safe_redirect( add_query_arg( 'aegis_notice', 'sessions_revoked', $base_url ) ); exit; } /* * New-device / new-IP login alerts. */ /** * Send an alert when a user signs in from a new IP address. * * @param mixed $user WordPress user object. * @param mixed $ip Client IP address. */ private static function maybe_alert_new_device( $user, $ip ) { if ( '' === $ip || empty( $user->user_email ) ) { return; } $known = (array) get_user_meta( $user->ID, self::META_KNOWN_IPS, true ); if ( in_array( $ip, $known, true ) ) { return; } if ( ! empty( $known ) && self::settings()['login_alert_enabled'] ) { $site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); $date = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); $subject = sprintf( '[%s] %s', $site_name, __( 'New sign-in to your account', 'aegis-user-guard' ) ); $message = self::render_notification_email( $site_name, __( 'from a new device', 'aegis-user-guard' ), $user, /* translators: %s is the client IP address. */ sprintf( __( 'IP address %s', 'aegis-user-guard' ), $ip ), $date, admin_url( 'profile.php' ), /* translators: %1$s is the username, %2$s is the IP address, and %3$s is the date. */ sprintf( __( 'Your account %1$s was just used to sign in from an IP address we haven\'t seen before (%2$s) on %3$s. If this was you, no action is needed. If you don\'t recognize this activity, change your password immediately and contact an administrator.', 'aegis-user-guard' ), $user->user_login, $ip, $date ) ); wp_mail( $user->user_email, $subject, $message, array( 'Content-Type: text/html; charset=UTF-8' ) ); self::log_activity( 'new_device_login', $user->ID, array( 'ip' => $ip ) ); } array_unshift( $known, $ip ); update_user_meta( $user->ID, self::META_KNOWN_IPS, array_slice( array_values( array_unique( $known ) ), 0, self::MAX_KNOWN_IPS ) ); } /* * CSV export. */ /** * Stream CSV content to the browser. * * @param mixed $filename Download filename. * @param mixed $header CSV header row. * @param mixed $rows CSV data rows. * @return void */ private static function stream_csv( $filename, $header, $rows ) { nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=' . $filename ); $out = fopen( 'php://output', 'w' ); fputcsv( $out, $header ); foreach ( $rows as $row ) { fputcsv( $out, $row ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- CSV output is streamed through php://output. fclose( $out ); exit; } /** * Handle the handle_export_activity callback. */ public static function handle_export_activity() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to export the activity log.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $log = get_option( self::OPTION_ACTIVITY, array() ); $log = is_array( $log ) ? $log : array(); $rows = array(); foreach ( $log as $entry ) { $target = ! empty( $entry['user_id'] ) ? get_userdata( absint( $entry['user_id'] ) ) : false; $actor = ! empty( $entry['actor_id'] ) ? get_userdata( absint( $entry['actor_id'] ) ) : false; $context = self::activity_context_text( $entry['event'], $entry['context'] ?? array() ); $rows[] = array( self::activity_label( $entry['event'] ), $target ? $target->user_login : '', $actor ? $actor->user_login : __( 'System', 'aegis-user-guard' ), gmdate( 'Y-m-d H:i:s', absint( $entry['time'] ) ) . ' UTC', $context, ); } self::log_activity( 'activity_exported', 0, array( 'rows' => count( $rows ) ) ); self::stream_csv( 'aegis-activity-log-' . gmdate( 'Ymd-His' ) . '.csv', array( __( 'Event', 'aegis-user-guard' ), __( 'Account', 'aegis-user-guard' ), __( 'Actor', 'aegis-user-guard' ), __( 'When (UTC)', 'aegis-user-guard' ), __( 'Context', 'aegis-user-guard' ), ), $rows ); } /** * Handle the handle_export_administrators callback. */ public static function handle_export_administrators() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to export the Administrator directory.', 'aegis-user-guard' ), 403 ); } check_admin_referer( self::NONCE ); $administrators = get_users( array( 'role' => 'administrator', 'orderby' => 'display_name', 'order' => 'ASC', ) ); $rows = array(); foreach ( $administrators as $administrator ) { $last_login = absint( get_user_meta( $administrator->ID, self::META_LAST_LOGIN, true ) ); $rows[] = array( $administrator->display_name ? $administrator->display_name : $administrator->user_login, $administrator->user_login, $administrator->user_email, $last_login ? gmdate( 'Y-m-d H:i:s', $last_login ) . ' UTC' : '', self::is_pending_admin_review( $administrator->ID ) ? __( 'Pending review', 'aegis-user-guard' ) : ( self::is_account_disabled( $administrator->ID ) ? __( 'Access paused', 'aegis-user-guard' ) : __( 'Enabled', 'aegis-user-guard' ) ), self::get_user_session_count( $administrator->ID ), ); } self::log_activity( 'administrators_exported', 0, array( 'rows' => count( $rows ) ) ); self::stream_csv( 'aegis-administrators-' . gmdate( 'Ymd-His' ) . '.csv', array( __( 'Name', 'aegis-user-guard' ), __( 'Username', 'aegis-user-guard' ), __( 'Email', 'aegis-user-guard' ), __( 'Last sign-in (UTC)', 'aegis-user-guard' ), __( 'Status', 'aegis-user-guard' ), __( 'Active sessions', 'aegis-user-guard' ), ), $rows ); } /* * Security hardening checklist. */ /** * Restrict the public REST user endpoint when configured. * * @param mixed $endpoints Registered REST endpoints. * @return mixed Filtered REST endpoints. */ public static function restrict_rest_user_endpoints( $endpoints ) { if ( is_user_logged_in() || empty( self::settings()['rest_restrict_users'] ) ) { return $endpoints; } foreach ( array_keys( $endpoints ) as $route ) { if ( preg_match( '#^/wp/v2/users(/|$)#', $route ) ) { unset( $endpoints[ $route ] ); } } return $endpoints; } /** * Handle the security_checklist callback. */ private static function security_checklist() { $checks = array(); $checks[] = array( 'label' => __( 'File editing disabled', 'aegis-user-guard' ), 'description' => __( 'The built-in plugin and theme file editor is disabled.', 'aegis-user-guard' ), 'status' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT, 'fix' => __( "Add define( 'DISALLOW_FILE_EDIT', true ); to wp-config.php.", 'aegis-user-guard' ), 'fix_kind' => 'manual', ); $debug_on = defined( 'WP_DEBUG' ) && WP_DEBUG; $display_on = ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY; $checks[] = array( 'label' => __( 'Debug output hidden', 'aegis-user-guard' ), 'description' => __( 'PHP errors and warnings are not displayed to site visitors.', 'aegis-user-guard' ), 'status' => ! ( $debug_on && $display_on ), 'fix' => __( 'Set WP_DEBUG_DISPLAY to false in wp-config.php (log to file instead).', 'aegis-user-guard' ), 'fix_kind' => 'manual', ); $checks[] = array( 'label' => __( 'Admin area served over HTTPS', 'aegis-user-guard' ), 'description' => __( 'wp-admin and login requests are encrypted in transit.', 'aegis-user-guard' ), 'status' => is_ssl() || ( defined( 'FORCE_SSL_ADMIN' ) && FORCE_SSL_ADMIN ), 'fix' => __( 'Install an SSL certificate and set FORCE_SSL_ADMIN to true.', 'aegis-user-guard' ), 'fix_kind' => 'manual', ); $default_admin = username_exists( 'admin' ); $checks[] = array( 'label' => __( 'No default "admin" username', 'aegis-user-guard' ), 'description' => __( 'A predictable "admin" account is a common brute-force target.', 'aegis-user-guard' ), 'status' => ! ( $default_admin && self::user_is_administrator( $default_admin ) ), 'fix' => __( 'Rename or remove the "admin" account, or ensure it is not an Administrator.', 'aegis-user-guard' ), 'fix_kind' => 'link', 'fix_url' => admin_url( 'users.php' ), 'fix_label' => __( 'Review users', 'aegis-user-guard' ), ); $checks[] = array( 'label' => __( 'Two-factor authentication required for Administrators', 'aegis-user-guard' ), 'description' => __( 'Administrators must confirm a second factor before signing in.', 'aegis-user-guard' ), 'status' => (bool) self::settings()['twofa_required'], 'fix' => __( 'Enable "Two-factor authentication" under Core controls.', 'aegis-user-guard' ), 'fix_kind' => 'toggle', 'fix_url' => '#aegis-tab-core', 'fix_label' => __( 'Enable in Core controls', 'aegis-user-guard' ), ); $rest_exposed = null; if ( ! empty( self::settings()['rest_restrict_users'] ) ) { // Our own filter blocks anonymous requests to this route; no need to guess further. $rest_exposed = false; } elseif ( function_exists( 'rest_get_server' ) ) { // No live route here reflects what an anonymous visitor sees, since this // request runs as the logged-in user viewing this page. Only trustworthy // when it comes back exposed (a hard true regardless of who is asking) or // when some other layer (another plugin, server config) already blocks it // for everyone, current user included. try { if ( ! did_action( 'rest_api_init' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Replays the native REST initialization hook for the checklist probe. do_action( 'rest_api_init', rest_get_server() ); } $request = new WP_REST_Request( 'GET', '/wp/v2/users' ); $response = rest_do_request( $request ); $rest_exposed = ! is_wp_error( $response ) && 200 === $response->get_status() && ! empty( $response->get_data() ); } catch ( Throwable $e ) { $rest_exposed = null; } } if ( null !== $rest_exposed ) { $checks[] = array( 'label' => __( 'REST API does not expose the user list', 'aegis-user-guard' ), 'description' => __( 'Usernames cannot be enumerated through /wp-json/wp/v2/users.', 'aegis-user-guard' ), 'status' => ! $rest_exposed, 'fix' => __( 'Restrict the wp/v2/users REST route to signed-in requests.', 'aegis-user-guard' ), 'fix_kind' => 'toggle', 'fix_url' => '#aegis-tab-core', 'fix_label' => __( 'Enable in Core controls', 'aegis-user-guard' ), ); } $core_updates = get_site_transient( 'update_core' ); $core_current = true; if ( $core_updates && ! empty( $core_updates->updates[0] ) ) { $core_current = 'latest' === $core_updates->updates[0]->response; } $checks[] = array( 'label' => __( 'WordPress core is up to date', 'aegis-user-guard' ), 'description' => __( 'No pending core security or maintenance release.', 'aegis-user-guard' ), 'status' => $core_current, 'fix' => __( 'A core update is available.', 'aegis-user-guard' ), 'fix_kind' => 'link', 'fix_url' => admin_url( 'update-core.php' ), 'fix_label' => __( 'Go to Updates', 'aegis-user-guard' ), ); $plugin_updates = get_site_transient( 'update_plugins' ); $pending_plugins = $plugin_updates && ! empty( $plugin_updates->response ) ? count( $plugin_updates->response ) : 0; $checks[] = array( 'label' => __( 'Plugins are up to date', 'aegis-user-guard' ), 'description' => __( 'No plugin updates are pending.', 'aegis-user-guard' ), 'status' => 0 === $pending_plugins, /* translators: %d is the number of plugins with updates. */ 'fix' => sprintf( _n( '%d plugin has an update available.', '%d plugins have an update available.', $pending_plugins, 'aegis-user-guard' ), $pending_plugins ), 'fix_kind' => 'link', 'fix_url' => admin_url( 'plugins.php' ), 'fix_label' => __( 'Review plugins', 'aegis-user-guard' ), ); return $checks; } }