WS_OK_7.4.33
<?php
namespace Simple_History\Loggers;
use Error;
use Simple_History\Event_Details\Event_Details_Group;
use Simple_History\Event_Details\Event_Details_Item;
use Simple_History\Existing_Data_Importer;
use Simple_History\Helpers;
use Simple_History\Log_Initiators;
/**
* Logs changes to user logins (and logouts).
*/
class User_Logger extends Logger {
/** @var string Logger slug */
public $slug = 'SimpleUserLogger';
/** @var array<int,array> Context for modified user. */
private $user_profile_update_modified_context = [];
/** @var array Detected WP Cli Changes */
private $wp_cli_changes = [
'user_roles_added' => [],
'user_roles_removed' => [],
];
/**
* Username captured from the `authenticate` filter chain.
*
* Needed for XML-RPC application-password failures, where the username travels
* inside the XML body and never lands in `$_SERVER['PHP_AUTH_USER']`. The
* `application_password_failed_authentication` action passes only the WP_Error,
* so we stash the value seen during `authenticate` and read it back here.
*
* @var string|null
*/
private $last_attempted_username = null;
/**
* Dedupe guard for `application_password_failed_authentication`.
*
* WP fires the action twice per request (during `authenticate` and again during
* `determine_current_user`), and the resolve-current-user call inside the log
* write can re-enter via `wp_validate_application_password`. We only want one
* log row per request.
*
* @var bool
*/
private $app_password_failure_logged = false;
/** @inheritDoc */
public function get_info() {
return array(
'name' => __( 'User Logger', 'simple-history' ),
'description' => __( 'Logs user logins, logouts, and failed logins', 'simple-history' ),
'capability' => 'edit_users',
'messages' => array(
'user_login_failed' => __(
'Failed to login with username "{login}" (incorrect password entered)',
'simple-history'
),
'user_unknown_login_failed' => __(
'Failed to login with username "{failed_username}" (username does not exist)',
'simple-history'
),
'user_logged_in' => __( 'Logged in', 'simple-history' ),
'user_unknown_logged_in' => __( 'Unknown user logged in', 'simple-history' ),
'user_logged_out' => __( 'Logged out', 'simple-history' ),
'user_updated_profile' => __(
'Edited the profile for user "{edited_user_login}" ({edited_user_email})',
'simple-history'
),
'user_created' => __(
'Created user {created_user_login} ({created_user_email}) with role {created_user_role}',
'simple-history'
),
'user_deleted' => __( 'Deleted user {deleted_user_login} ({deleted_user_email})', 'simple-history' ),
'user_password_reseted' => __( 'Reset their password', 'simple-history' ),
'user_requested_password_reset_link' => __(
"Requested a password reset link for user with login '{user_login}' and email '{user_email}'",
'simple-history'
),
'user_session_destroy_others' => _x(
'Logged out from all other sessions',
'User destroys other login sessions for themself',
'simple-history'
),
'user_session_destroy_everywhere' => _x(
'Logged out "{user_display_name}" from all sessions',
'User destroys all login sessions for a user',
'simple-history'
),
'user_role_updated' => _x(
'Changed role for user "{edited_user_login}" to "{new_role}" from "{old_role}"',
'User updates the role for a user',
'simple-history'
),
'user_role_added' => _x(
'Added role(s) "{roles}" to user "{edited_user_login}"',
'A role is added to a user',
'simple-history'
),
'user_role_removed' => _x(
'Removed role(s) "{roles}" from user "{edited_user_login}"',
'A role is removed from a user',
'simple-history'
),
'user_admin_email_confirm_correct_clicked' => _x(
'Verified that administration email for website is correct',
'User clicks confirm admin email on admin email confirm screen',
'simple-history'
),
'user_application_password_created' => _x(
'Added application password "{application_password_name}" for user "{edited_user_login}"',
'User add new application password',
'simple-history'
),
'user_application_password_revoked' => _x(
'Revoked application password "{application_password_name}" for user "{edited_user_login}"',
'User revoke application password',
'simple-history'
),
'user_application_password_login_failed' => _x(
'Failed to authenticate with application password for user "{login}"',
'Failed application password authentication on REST/XML-RPC',
'simple-history'
),
'user_application_password_unknown_login_failed' => _x(
'Failed to authenticate with application password for user "{failed_username}" (user does not exist)',
'Failed application password authentication for unknown user',
'simple-history'
),
'user_admin_page_access_denied' => _x(
'Attempted to access restricted admin page "{admin_page}"',
'User was denied access to an admin page',
'simple-history'
),
),
'labels' => array(
'search' => array(
'label' => _x( 'Users', 'User logger: search', 'simple-history' ),
'label_all' => _x( 'All user activity', 'User logger: search', 'simple-history' ),
'options' => array(
_x( 'Successful user logins', 'User logger: search', 'simple-history' ) => array(
'user_logged_in',
'user_unknown_logged_in',
),
_x( 'Failed user logins', 'User logger: search', 'simple-history' ) => array(
'user_login_failed',
'user_unknown_login_failed',
),
_x( 'Failed login (known user)', 'User logger: search', 'simple-history' ) => array(
'user_login_failed',
),
_x( 'Failed login (unknown user)', 'User logger: search', 'simple-history' ) => array(
'user_unknown_login_failed',
),
_x( 'User logouts', 'User logger: search', 'simple-history' ) => array(
'user_logged_out',
),
_x( 'Created users', 'User logger: search', 'simple-history' ) => array(
'user_created',
),
_x( 'User profile updates', 'User logger: search', 'simple-history' ) => array(
'user_updated_profile',
),
_x( 'User role changes', 'User logger: search', 'simple-history' ) => array(
'user_role_updated',
),
_x( 'User deletions', 'User logger: search', 'simple-history' ) => array(
'user_deleted',
),
_x( 'User application password created', 'User logger: search', 'simple-history' ) => array(
'user_application_password_created',
),
_x( 'User application password deletion', 'User logger: search', 'simple-history' ) => array(
'user_application_password_revoked',
),
_x( 'Failed application password authentication', 'User logger: search', 'simple-history' ) => array(
'user_application_password_login_failed',
'user_application_password_unknown_login_failed',
),
_x( 'Admin page access denied', 'User logger: search', 'simple-history' ) => array(
'user_admin_page_access_denied',
),
),
),
),
);
}
/**
* Add actions and filters when logger is loaded by Simple History
*/
public function loaded() {
// Plain logins and logouts.
add_action( 'wp_login', array( $this, 'on_wp_login' ), 1, 2 );
add_action( 'wp_logout', array( $this, 'onWpLogout' ), 10, 1 );
// Failed login attempt to username that exists.
add_action( 'wp_authenticate_user', array( $this, 'onWpAuthenticateUser' ), 10, 2 );
// Failed to login to user that did not exist (perhaps brute force)
// run this later than 10 because WordPress own email login check is done with priority 20
// so if we run at 10 we just get null.
add_filter( 'authenticate', array( $this, 'onAuthenticate' ), 30, 3 );
// User is created.
add_action( 'user_register', array( $this, 'on_user_register' ), 10, 2 );
// User is deleted.
add_action( 'delete_user', array( $this, 'onDeleteUser' ), 10, 2 );
// User sessions is destroyed. AJAX call that we hook onto early.
add_action( 'wp_ajax_destroy-sessions', array( $this, 'onDestroyUserSession' ), 0 );
// User reaches reset password (from link or only from user created link).
add_action( 'validate_password_reset', array( $this, 'onValidatePasswordReset' ), 10, 2 );
add_action( 'retrieve_password_message', array( $this, 'onRetrievePasswordMessage' ), 10, 4 );
// New way, fired before update so we can get old user data. Does not commit, that's done in another hook.
add_filter( 'wp_pre_insert_user_data', array( $this, 'on_pre_insert_user_data_collect' ), 10, 4 );
// Commit changes to user profile. Run on hook with higher prio, so other plugins,
// for example the "Members" plugin for roles, can modify user data before we commit.
add_action( 'profile_update', [ $this, 'on_profile_update_commit' ], 50, 1 );
add_action( 'set_user_role', array( $this, 'on_set_user_role_on_admin_overview_screen' ), 10, 3 );
// Administration email verification-screen.
add_action( 'login_form_confirm_admin_email', array( $this, 'on_action_login_form_confirm_admin_email' ) );
add_action( 'wp_create_application_password', array( $this, 'on_action_wp_create_application_password' ), 10, 4 );
add_action( 'wp_delete_application_password', array( $this, 'on_action_wp_delete_application_password' ), 10, 2 );
// TODO: there is also an action "wp_update_application_password". Used by rest api and fired when a user updates app password there.
// Failed application password authentication (REST/XML-RPC).
// Gated behind experimental features while we validate noise levels in the wild.
// Sites can additionally opt out via the `simple_history/log_failed_app_password_auth` filter.
/**
* Filter whether failed application password authentication attempts should be logged.
*
* Defaults to true when experimental features are enabled. Set to false to disable
* logging without turning off experimental features entirely (useful on sites with
* heavy API traffic where the log would fill with brute-force noise).
*
* @since 5.x
*
* @param bool $should_log Whether to log failed application password authentication.
*/
$log_failed_app_password_auth = apply_filters(
'simple_history/log_failed_app_password_auth',
Helpers::experimental_features_is_enabled()
);
if ( $log_failed_app_password_auth ) {
// Capture the attempted username just before WP's wp_authenticate_application_password
// (priority 20) runs, so the XML-RPC code path — where PHP_AUTH_USER is empty — still
// has a username to log.
add_filter( 'authenticate', array( $this, 'capture_attempted_username' ), 19, 3 );
add_action( 'application_password_failed_authentication', array( $this, 'on_application_password_failed_authentication' ), 10, 1 );
}
// Admin page access denied.
add_action( 'admin_page_access_denied', array( $this, 'on_admin_page_access_denied' ), 10 );
$this->add_wp_cli_hooks();
}
/**
* Adds hooks for WP CLI.
*/
private function add_wp_cli_hooks() {
if ( ! Helpers::is_wp_cli() ) {
return;
}
// Add hooks to collect and commit add-role changes.
\WP_CLI::add_hook( 'before_invoke:user add-role', [ $this, 'on_wp_cli_before_invoke_user_add_role_collect' ] );
\WP_CLI::add_hook( 'after_invoke:user add-role', [ $this, 'on_wp_cli_after_invoke_user_add_role_commit_changes' ] );
// Add hooks to collect and commit remove-role changes.
\WP_CLI::add_hook( 'before_invoke:user remove-role', [ $this, 'on_wp_cli_before_invoke_user_remove_role_collect' ] );
\WP_CLI::add_hook( 'after_invoke:user remove-role', [ $this, 'on_wp_cli_after_invoke_user_remove_role_commit_changes' ] );
}
/**
* Collect user add role changes.
*/
public function on_wp_cli_before_invoke_user_add_role_collect() {
add_action( 'add_user_role', [ $this, 'on_wp_cli_add_user_role_collect' ], 10, 2 );
}
/**
* Commit changes when functions has run.
*/
public function on_wp_cli_after_invoke_user_add_role_commit_changes() {
// Keyed by user id. Roles as array.
$user_roles_added = $this->wp_cli_changes['user_roles_added'];
foreach ( $user_roles_added as $user_id => $roles ) {
$user = get_user_by( 'ID', $user_id );
$context = [
'roles' => implode( ', ', $roles ),
'edited_user_id' => $user_id,
'edited_user_email' => $user->user_email,
'edited_user_login' => $user->user_login,
];
$this->info_message( 'user_role_added', $context );
}
}
/**
* Collect user add role changes.
*/
public function on_wp_cli_before_invoke_user_remove_role_collect() {
add_action( 'remove_user_role', [ $this, 'on_wp_cli_remove_user_role_collect' ], 10, 2 );
}
/**
* Commit changes when functions has run.
*/
public function on_wp_cli_after_invoke_user_remove_role_commit_changes() {
// Keyed by user id. Roles as array.
$user_roles_removed = $this->wp_cli_changes['user_roles_removed'];
foreach ( $user_roles_removed as $user_id => $roles ) {
$user = get_user_by( 'ID', $user_id );
$context = [
'roles' => implode( ', ', $roles ),
'edited_user_id' => $user_id,
'edited_user_email' => $user->user_email,
'edited_user_login' => $user->user_login,
];
$this->info_message( 'user_role_removed', $context );
}
}
/**
* Collect changes when user role is removed via WP CLI.
*
* @param mixed $user_id User id.
* @param mixed $role Role that was removed.
*/
public function on_wp_cli_remove_user_role_collect( $user_id, $role ) {
if ( ! isset( $this->wp_cli_changes['user_roles_removed'][ $user_id ] ) ) {
$this->wp_cli_changes['user_roles_removed'][ $user_id ] = [];
}
$this->wp_cli_changes['user_roles_removed'][ $user_id ][] = $role;
}
/**
* From WP_CLI this filter is only called when a user role is actually added,
* it is not fired if the user already has the role.
*
* The filter will be called multiple times if multiple roles are added at the same time.
*
* @param mixed $user_id The user ID.
* @param mixed $role The new role.
*/
public function on_wp_cli_add_user_role_collect( $user_id, $role ) {
if ( ! isset( $this->wp_cli_changes['user_roles_added'][ $user_id ] ) ) {
$this->wp_cli_changes['user_roles_added'][ $user_id ] = [];
}
$this->wp_cli_changes['user_roles_added'][ $user_id ][] = $role;
}
/**
* Log when an Application Password is created for a user.
*
* Fired from action `wp_create_application_password`.
*
* @param int $user_id The user ID.
* @param array $item {
* The details about the created password.
*
* @type string $uuid The unique identifier for the application password.
* @type string $app_id A UUID provided by the application to uniquely identify it.
* @type string $name The name of the application password.
* @type string $password A one-way hash of the password.
* @type int $created Unix timestamp of when the password was created.
* @type null $last_used Null.
* @type null $last_ip Null.
* }
* @param string $new_password The unhashed generated application password.
* @param array $args {
* Arguments used to create the application password.
*
* @type string $name The name of the application password.
* @type string $app_id A UUID provided by the application to uniquely identify it.
* }
*/
public function on_action_wp_create_application_password( $user_id, $item, $new_password, $args ) {
$user = get_user_by( 'ID', $user_id );
$this->info_message(
'user_application_password_created',
array(
'edited_user_id' => $user_id,
'edited_user_email' => $user->user_email,
'edited_user_login' => $user->user_login,
'application_password_name' => $item['name'],
)
);
}
/**
* Log when an Application password is deleted (revoked).
* Fired from action `wp_delete_application_password`.
*
* @since 5.6.0
*
* @param int $user_id The user ID.
* @param array $item The data about the application password.
*/
public function on_action_wp_delete_application_password( $user_id, $item ) {
$user = get_user_by( 'ID', $user_id );
$this->info_message(
'user_application_password_revoked',
array(
'edited_user_id' => $user_id,
'edited_user_email' => $user->user_email,
'edited_user_login' => $user->user_login,
'application_password_name' => $item['name'],
)
);
}
/**
* Capture the attempted username from the `authenticate` filter chain.
*
* The `application_password_failed_authentication` action only receives the WP_Error,
* not the username. For XML-RPC requests the username never reaches `$_SERVER['PHP_AUTH_USER']`,
* so without this we'd log an empty value.
*
* This fires only for paths that go through `wp_authenticate()` — primarily XML-RPC.
* The REST API code path (`determine_current_user` -> `wp_validate_application_password()`
* -> `wp_authenticate_application_password()`) bypasses the `authenticate` filter chain
* entirely, which is why the failed-auth handler also falls back to `$_SERVER['PHP_AUTH_USER']`.
*
* @param null|\WP_User|\WP_Error $user Authenticated user, error, or null.
* @param string $username The username or email being authenticated.
* @param string $password The password being authenticated.
* @return null|\WP_User|\WP_Error Unchanged $user — we only observe.
*/
public function capture_attempted_username( $user, $username, $password ) {
$this->last_attempted_username = (string) $username;
return $user;
}
/**
* Log failed application password authentication.
*
* Fired from action `application_password_failed_authentication` (since WP 5.6.0)
* when an application password fails to authenticate a REST or XML-RPC request.
*
* Possible error codes from WordPress core:
* - `invalid_username` / `invalid_email` — username does not exist.
* - `incorrect_password` — wrong application password.
* - `application_passwords_disabled` — disabled site-wide.
* - `application_passwords_disabled_for_user` — disabled for this user.
*
* @param \WP_Error $error The authentication error.
*/
public function on_application_password_failed_authentication( $error ) {
// Guard against recursion: resolving the current user during the log write can
// re-trigger `determine_current_user` -> `wp_validate_application_password` and
// fire this action again. Also dedupes the action firing twice per request
// (once during `authenticate`, once during `determine_current_user`).
if ( $this->app_password_failure_logged ) {
return;
}
$this->app_password_failure_logged = true;
// Prefer the username captured during the `authenticate` filter chain — this is
// the only source that works for XML-RPC, where the username is inside the XML
// body and never lands in PHP_AUTH_USER.
//
// Fall back to PHP_AUTH_USER for the REST API code path: WP fires the action
// from `wp_validate_application_password()` (hooked on `determine_current_user`),
// which calls `wp_authenticate_application_password()` *directly* — bypassing the
// `authenticate` filter chain, so our capture at priority 19 never runs there.
$captured_username = (string) ( $this->last_attempted_username ?? '' );
if ( $captured_username !== '' ) {
$attempted_username = sanitize_text_field( $captured_username );
} else {
// phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.BasicAuthentication -- Reading the attempted username for security logging only; authentication is handled by WP core.
$attempted_username = sanitize_text_field( wp_unslash( $_SERVER['PHP_AUTH_USER'] ?? '' ) );
}
$error_code = is_a( $error, 'WP_Error' ) ? $error->get_error_code() : '';
$error_message = is_a( $error, 'WP_Error' ) ? wp_strip_all_tags( $error->get_error_message() ) : '';
$context = array(
'_initiator' => Log_Initiators::WEB_USER,
'error_code' => (string) $error_code,
'error_message' => $error_message,
'request_uri' => sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ),
'request_method' => sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ),
// phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__ -- User agent logging important for security (brute force detection). Accept VIP caching limitation.
'server_http_user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ),
'_occasionsID' => self::class . '/failed_application_password_login',
);
$is_unknown_user = in_array( $error_code, array( 'invalid_username', 'invalid_email' ), true );
if ( $is_unknown_user ) {
$context['failed_username'] = $attempted_username;
$this->warning_message( 'user_application_password_unknown_login_failed', $context );
return;
}
$context['login'] = $attempted_username;
$user = $attempted_username !== '' ? get_user_by( 'login', $attempted_username ) : false;
if ( ! $user && is_email( $attempted_username ) ) {
$user = get_user_by( 'email', $attempted_username );
}
if ( $user instanceof \WP_User ) {
$context['login_id'] = $user->ID;
$context['login_email'] = $user->user_email;
}
$this->warning_message( 'user_application_password_login_failed', $context );
}
/**
* Fires after the user's role has changed.
*
* Captures role changes from the users admin overview screen (quick edit),
* REST API, and WP-CLI. Profile-screen role changes flow through the
* profile_update / pre_user_data path instead.
*
* @param int $user_id The user ID.
* @param string $role The new role.
* @param string[] $old_roles An array of the user's previous roles.
*/
public function on_set_user_role_on_admin_overview_screen( $user_id, $role, $old_roles ) {
$is_users_screen = false;
if ( is_admin() ) {
$current_screen = helpers::get_current_screen();
$is_users_screen = isset( $current_screen->id ) && $current_screen->id === 'users';
}
if ( ! $is_users_screen && ! Helpers::is_rest_request() && ! Helpers::is_wp_cli() ) {
return;
}
// Suppress the implicit role assignment that wp_insert_user() does during
// user creation — the user_created event already records the role. New
// users have no previous roles, so $old_roles is empty. Edge case: an
// admin who explicitly cleared a user's roles first (set_role('')) and
// then assigns a new one would also hit this branch on the second call
// and not be logged. Considered acceptable — the clearing call itself
// is logged.
if ( empty( $old_roles ) ) {
return;
}
$changed_user = get_user_by( 'ID', $user_id );
if ( ! is_array( $old_roles ) ) {
$old_roles = array();
}
$old_role = (string) reset( $old_roles );
$this->notice_message(
'user_role_updated',
array(
'edited_user_id' => $user_id,
'edited_user_email' => $changed_user->user_email,
'edited_user_login' => $changed_user->user_login,
'new_role' => $role,
'old_role' => $old_role,
)
);
}
/**
* Log when user confirms that admin email is correct.
* Fired from filter 'login_form_confirm_admin_email'.
*/
public function on_action_login_form_confirm_admin_email() {
// Bail if button with name "correct-admin-email" was not clicked or if no nonce field exists.
if ( empty( $_POST['confirm_admin_email_nonce'] ) || empty( $_POST['correct-admin-email'] ) ) {
return;
}
// Bail if nonce not valid.
$nonce_valid = wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['confirm_admin_email_nonce'] ) ), 'confirm_admin_email' );
if ( $nonce_valid === false ) {
return;
}
$this->info_message( 'user_admin_email_confirm_correct_clicked' );
}
/**
* Commits the context previously collected.
* In this hook the changes are written, so we can get any new data from the user object.
*
* @param int $user_id ID of the user that was created or updated.
*/
public function on_profile_update_commit( $user_id ) {
$context = $this->user_profile_update_modified_context[ $user_id ] ?? null;
// Bail if we don't have any context.
if ( ! $context ) {
return;
}
$user = get_user_by( 'ID', $user_id );
// Get new roles, to detect changes.
$context['user_new_roles'] = (array) $user->roles;
$prev_roles = $context['user_prev_roles'] ?? [];
$added_roles = array_values( array_diff( $context['user_new_roles'], $prev_roles ) );
if ( $added_roles ) {
// Comma separated list of added roles.
$context['user_added_roles'] = implode( ', ', $added_roles );
}
$removed_roles = array_values( array_diff( $prev_roles, $context['user_new_roles'] ) );
if ( $removed_roles ) {
// Comma separated list of removed roles.
$context['user_removed_roles'] = implode( ', ', $removed_roles );
}
// Remove keys used for diff.
unset( $context['user_prev_roles'], $context['user_new_roles'] );
$this->info_message( 'user_updated_profile', $context );
}
/**
* Filters user data before the record is created or updated.
* Used to gather user profile updates.
*
* It only includes data in the users table, not any user metadata.
*
* @param array $data {
* Values and keys for the user.
*
* @type string $user_login The user's login. Only included if $update == false
* @type string $user_pass The user's password.
* @type string $user_email The user's email.
* @type string $user_url The user's url.
* @type string $user_nicename The user's nice name. Defaults to a URL-safe version of user's login
* @type string $display_name The user's display name.
* @type string $user_registered MySQL timestamp describing the moment when the user registered. Defaults to
* the current UTC timestamp.
* }
* @param bool $update Whether the user is being updated rather than created.
* @param int|null $user_id ID of the user to be updated, or NULL if the user is being created.
* @param array $userdata The raw array of data passed to wp_insert_user().
*/
public function on_pre_insert_user_data_collect( $data, $update, $user_id, $userdata = array() ) {
// Bail if this is not a user update, i.e. only collect for user edits.
if ( ! $update ) {
return $data;
}
// Bail if we don't have all needed data.
if ( ! $data || ! $user_id ) {
return $data;
}
// Only capture in contexts where changes are intentional: admin user screens,
// REST API, or WP-CLI. Arbitrary wp_update_user() calls from cron or plugins
// are excluded to avoid noise.
$is_admin_user_screen = false;
if ( is_admin() ) {
$current_screen = helpers::get_current_screen();
$is_admin_user_screen = in_array( $current_screen->id, array( 'user-edit', 'profile' ), true );
}
if ( ! $is_admin_user_screen && ! Helpers::is_rest_request() && ! Helpers::is_wp_cli() ) {
return $data;
}
// Array with differences between old and new values.
$user_data_diff = array();
// Get user object that contains old/existing values.
$user_before_update = get_user_by( 'ID', $user_id );
$password_changed = false;
foreach ( $userdata as $option_key => $one_maybe_updated_option_value ) {
$prev_option_value = $user_before_update->$option_key;
$add_diff = true;
// Some options need special treatment.
if ( $option_key === 'user_pass' ) {
$password_changed = $one_maybe_updated_option_value !== $prev_option_value;
$add_diff = false;
} elseif ( $option_key === 'comment_shortcuts' ) {
if ( empty( $one_maybe_updated_option_value ) ) {
$one_maybe_updated_option_value = 'false';
}
} elseif ( $option_key === 'locale' ) {
if ( $one_maybe_updated_option_value === '' ) {
$one_maybe_updated_option_value = 'SITE_DEFAULT';
}
if ( $prev_option_value === '' ) {
$prev_option_value = 'SITE_DEFAULT';
}
}
if ( ! $add_diff ) {
continue;
}
$user_data_diff = $this->addDiff( $user_data_diff, $option_key, $prev_option_value, $one_maybe_updated_option_value );
}
// Setup basic context.
$context = array(
'edited_user_id' => $user_id,
'edited_user_email' => $user_before_update->user_email,
'edited_user_login' => $user_before_update->user_login,
);
if ( $password_changed ) {
$context['edited_user_password_changed'] = '1';
}
// Add diff to context.
foreach ( $user_data_diff as $one_diff_key => $one_diff_vals ) {
$context[ "user_prev_{$one_diff_key}" ] = $one_diff_vals['old'];
$context[ "user_new_{$one_diff_key}" ] = $one_diff_vals['new'];
}
$context['user_prev_roles'] = (array) $user_before_update->roles;
// Store in private var to retrieve in commit function.
$this->user_profile_update_modified_context[ $user_id ] = $context;
return $data;
}
/**
* Fired from hook "retrieve_password_message" in "wp-login.php".
* Hook filters the message body of the password reset mail.
*
* If this hook is fired then WP has checked for valid username etc already.
*
* This hook is not fired when using for example WooCommerce because it has it's own reset password system.
* Maybe get_password_reset_key() can be used instead?
*
* @param string $message Default mail message.
* @param string $key The activation key.
* @param string $user_login The username for the user.
* @param object $user_data WP_User object.
*/
public function onRetrievePasswordMessage( $message, $key, $user_login, $user_data = null ) {
// Do not log $message: it contains the password reset URL (with the activation key),
// which is credential-equivalent until the key expires or is consumed.
$context = array(
'user_login' => $user_login,
'user_email' => $user_data->user_email,
);
// Request to send reset password link
// can be initiated from login screen or from users-listing-page in admin.
// Detect where from the request is coming.
$request_origin = 'unknown';
if ( function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
if ( $screen && $screen->base === 'users' ) {
$request_origin = 'wp_admin_users_admin';
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing
} elseif ( ! empty( $_POST['user_login'] ) ) {
$request_origin = 'login_screen';
}
if ( $request_origin === 'login_screen' ) {
$context['_initiator'] = Log_Initiators::WEB_USER;
}
$this->notice_message( 'user_requested_password_reset_link', $context );
return $message;
}
/**
* Fired before the password reset procedure is validated.
*
* @param \WP_Error $errors WP Error object.
* @param \WP_User|\WP_Error $user WP_User object if the login and reset key match. WP_Error object otherwise.
*/
public function onValidatePasswordReset( $errors, $user ) {
$context = array();
if ( is_a( $user, 'WP_User' ) ) {
$context['_initiator'] = Log_Initiators::WP_USER;
$context['_user_id'] = $user->ID;
$context['_user_login'] = $user->user_login;
$context['_user_email'] = $user->user_email;
}
// PHPCS:ignore WordPress.Security.NonceVerification.Missing
if ( $errors->get_error_code() || ! isset( $_POST['pass1'] ) || empty( $_POST['pass1'] ) ) {
return;
}
$this->info_message( 'user_password_reseted', $context );
}
/**
* Called when user sessions are destroyed from admin.
* Can be called for current logged in user = destroy all other sessions.
* or for another user = destroy all sessions for that user
* Fires from AJAX call.
*
* @since 2.0.6
*/
public function onDestroyUserSession() {
// PHPCS:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotValidated
$user = get_userdata( (int) $_POST['user_id'] );
if ( $user ) {
if ( ! current_user_can( 'edit_user', $user->ID ) ) {
$user = false;
} elseif ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'update-user_' . $user->ID ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
$user = false;
}
}
if ( ! $user ) {
// Could not log out user sessions. Please try again.
return;
}
$context = array();
if ( $user->ID === get_current_user_id() ) {
$this->info_message( 'user_session_destroy_others' );
} else {
$context['user_id'] = $user->ID;
$context['user_login'] = $user->user_login;
$context['user_display_name'] = $user->display_name;
$this->info_message( 'user_session_destroy_everywhere', $context );
}
}
/**
* Fires before a user is deleted from the database.
*
* @param int $user_id ID of the deleted user.
* @param int|null $reassign ID of the user to reassign posts and links to.
* Default null, for no reassignment.
*/
public function onDeleteUser( $user_id, $reassign ) {
$wp_user_to_delete = get_userdata( $user_id );
$context = array(
'deleted_user_id' => $wp_user_to_delete->ID,
'deleted_user_email' => $wp_user_to_delete->user_email,
'deleted_user_login' => $wp_user_to_delete->user_login,
'deleted_user_role' => implode( ', ', $wp_user_to_delete->roles ),
'reassign_user_id' => $reassign,
);
$this->notice_message( 'user_deleted', $context );
}
/**
* Modify plain text row output
* - adds link to user profile
* - change to "your profile" if you're looking at your own edit
*
* @param object $row Log row.
* @return string
*/
public function get_log_row_plain_text_output( $row ) {
$context = $row->context;
$output = parent::get_log_row_plain_text_output( $row );
$current_user_id = get_current_user_id();
if ( $context['_message_key'] === 'user_updated_profile' ) {
$wp_user = get_user_by( 'id', $context['edited_user_id'] );
// If edited_user_id and _user_id is the same then a user edited their own profile
// Note: it's not the same thing as the currently logged in user (but.. it can be!).
if ( ! empty( $context['_user_id'] ) && $context['edited_user_id'] === $context['_user_id'] ) {
if ( $wp_user ) {
$context['edit_profile_link'] = get_edit_user_link( $wp_user->ID );
$use_you = apply_filters( 'simple_history/user_logger/plain_text_output_use_you', true );
// User still exist, so link to their profile.
if ( $current_user_id === (int) $context['_user_id'] && $use_you ) {
// User that is viewing the log is the same as the edited user.
$msg = __( 'Edited <a href="{edit_profile_link}">your profile</a>', 'simple-history' );
} else {
$msg = __( 'Edited <a href="{edit_profile_link}">their profile</a>', 'simple-history' );
}
$output = helpers::interpolate( $msg, $context, $row );
} else {
// User does not exist any longer.
$output = __( 'Edited your profile', 'simple-history' );
}
} elseif ( $wp_user ) {
// User edited another users profile
// Edited user still exist, so link to their profile.
$context['edit_profile_link'] = get_edit_user_link( $wp_user->ID );
$msg = __( 'Edited the profile for user <a href="{edit_profile_link}">{edited_user_login} ({edited_user_email})</a>', 'simple-history' );
$output = helpers::interpolate( $msg, $context, $row );
}
} elseif ( $context['_message_key'] === 'user_created' ) {
// A user was created. Create link of username that goes to user profile.
$wp_user = get_user_by( 'id', $context['created_user_id'] );
if ( $wp_user ) {
$context['edit_profile_link'] = get_edit_user_link( $wp_user->ID );
// Use simplified message for imported users (no email/role placeholders).
// Imported users don't have email/role stored since those can change over time.
if ( isset( $context[ Existing_Data_Importer::BACKFILLED_CONTEXT_KEY ] ) ) {
$msg = __(
'Created user <a href="{edit_profile_link}">{created_user_login}</a>',
'simple-history'
);
} else {
$msg = __(
'Created user <a href="{edit_profile_link}">{created_user_login} ({created_user_email})</a> with role {created_user_role}',
'simple-history'
);
}
$output = helpers::interpolate(
$msg,
$context,
$row
);
}
}
return $output;
}
/**
* User logs in
*
* @param string $user_login Username.
* @param object $user WP_User object.
*/
public function on_wp_login( $user_login = null, $user = null ) {
$user_obj = null;
$context = array(
'user_login' => $user_login,
);
if ( isset( $user_login ) ) {
$user_obj = get_user_by( 'login', $user_login );
} elseif ( isset( $user ) && isset( $user->ID ) ) {
$user_obj = get_user_by( 'id', $user->ID );
}
if ( is_a( $user_obj, 'WP_User' ) ) {
$context = array(
'user_id' => $user_obj->ID,
'user_email' => $user_obj->user_email,
'user_login' => $user_obj->user_login,
);
// Override some data that is usually set automagically by Simple History
// Because wp_get_current_user() does not return any data yet at this point.
$context['_initiator'] = Log_Initiators::WP_USER;
$context['_user_id'] = $user_obj->ID;
$context['_user_login'] = $user_obj->user_login;
$context['_user_email'] = $user_obj->user_email;
// phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__ -- User agent logging important for security (brute force detection). Accept VIP caching limitation.
$context['server_http_user_agent'] = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
$this->info_message( 'user_logged_in', $context );
} else {
// Could not get any info about the user logging in.
$this->warning_message( 'user_unknown_logged_in', $context );
}
}
/**
* User logs out
* http://codex.wordpress.org/Plugin_API/Action_Reference/wp_logout
*
* @param int $user_id ID of the user that was logged out. Added in WP 5.5.
*/
public function onWpLogout( $user_id = null ) {
$context = array();
$user = get_userdata( $user_id );
if ( is_a( $user, 'WP_User' ) ) {
$context['_initiator'] = Log_Initiators::WP_USER;
$context['_user_id'] = $user->ID;
$context['_user_login'] = $user->user_login;
$context['_user_email'] = $user->user_email;
}
$this->info_message( 'user_logged_out', $context );
}
/**
* User is created. Fired from action user_register.
* Fires immediately after a new user is registered.
*
* @param int $user_id User ID.
* @param array $userdata The raw array of data passed to wp_insert_user().
*/
public function on_user_register( $user_id, $userdata = array() ) {
if ( ! $user_id || ! is_numeric( $user_id ) ) {
return;
}
$wp_user_added = get_userdata( $user_id );
$role = '';
$roles = [];
// On a subsite of a multisite network,
// newly created users have no roles or caps until they are added to a blog.
// So at this time we can't get the role of the user, if on a subsite.
// Use value from $_POST instead.
if ( is_multisite() ) {
// PHPCS:ignore WordPress.Security.NonceVerification.Missing
$role = sanitize_title( wp_unslash( $_POST['role'] ?? '' ) );
$roles = array( $role );
} elseif ( is_array( $wp_user_added->roles ) && ! empty( $wp_user_added->roles[0] ) ) {
// Single site, get role from user object.
$roles = $wp_user_added->roles;
}
// PHPCS:ignore WordPress.Security.NonceVerification.Missing
$send_user_notification = (int) ( isset( $_POST['send_user_notification'] ) && sanitize_text_field( wp_unslash( $_POST['send_user_notification'] ) ) );
$context = array(
'created_user_id' => $wp_user_added->ID,
'created_user_email' => $wp_user_added->user_email,
'created_user_login' => $wp_user_added->user_login,
'created_user_first_name' => $wp_user_added->first_name,
'created_user_last_name' => $wp_user_added->last_name,
'created_user_url' => $wp_user_added->user_url,
'created_user_role' => implode( ', ', $roles ),
'send_user_notification' => $send_user_notification,
);
$this->info_message( 'user_created', $context );
}
/**
* Log failed login attempt to username that exists
*
* @param \WP_User|\WP_Error $userOrError The WP_User object of the user being edited,
* or a WP_Error object if validation has already failed.
* @param string $password The user's password.
*/
public function onWpAuthenticateUser( $userOrError, $password ) {
if ( ! is_a( $userOrError, 'WP_User' ) ) {
return $userOrError;
}
// Only log failed attempts.
if ( ! wp_check_password( $password, $userOrError->user_pass, $userOrError->ID ) ) {
// Overwrite some vars that Simple History set automagically.
$context = array(
'_initiator' => Log_Initiators::WEB_USER,
'login_id' => $userOrError->ID,
'login_email' => $userOrError->user_email,
'login' => $userOrError->user_login,
// phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__ -- User agent logging important for security (brute force detection). Accept VIP caching limitation.
'server_http_user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ),
'_occasionsID' => self::class . '/failed_user_login',
);
$this->warning_message( 'user_login_failed', $context );
}
return $userOrError;
}
/**
* Attempt to login to user that does not exist
*
* @param \WP_User|\WP_Error|null $user (required)
* null indicates no process has authenticated the user yet.
* A WP_Error object indicates another process has failed the authentication.
* A WP_User object indicates another process has authenticated the user.
* @param string $username The user's username. since 4.5.0 `$username` now accepts an email address.
* @param string $password The user's password (encrypted).
*/
public function onAuthenticate( $user, $username, $password ) {
// Don't log empty usernames.
if ( trim( $username ) === '' ) {
return $user;
}
// If null then no auth done yet. Weird. But what can we do.
if ( is_null( $user ) ) {
return $user;
}
// If auth ok then $user is a wp_user object.
if ( is_a( $user, 'WP_User' ) ) {
return $user;
}
// If user is a WP_Error object then auth failed
// Error codes can be:
// "incorrect_password" | "empty_password" | "invalid_email" | "invalid_username"
// We only act on invalid emails and invalid usernames.
if ( is_a( $user, 'WP_Error' ) && ( $user->get_error_code() === 'invalid_username' || $user->get_error_code() === 'invalid_email' ) ) {
$context = array(
'_initiator' => Log_Initiators::WEB_USER,
'failed_username' => $username,
// phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__ -- User agent logging important for security (brute force detection). Accept VIP caching limitation.
'server_http_user_agent' => sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ),
// Count all failed logins to unknown users as the same occasions,
// to prevent log being flooded with login/hack attempts.
'_occasionsID' => self::class . '/failed_user_login',
);
$this->warning_message( 'user_unknown_login_failed', $context );
}
return $user;
}
/**
* Add diff to diff array if old and new values are different.
*
* Since 2.0.29
*
* @param array $post_data_diff Array with diffs.
* @param string $key Key in $post_data_diff array.
* @param string $old_value Old value.
* @param string $new_value New value.
* @return array $post_data_diff
*/
public function addDiff( $post_data_diff, $key, $old_value, $new_value ) {
// phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- Loose comparison intentional to avoid false diffs when types differ.
if ( $old_value != $new_value ) {
$post_data_diff[ $key ] = array(
'old' => $old_value,
'new' => $new_value,
);
}
return $post_data_diff;
}
/**
* Return more info about an logged event.
*
* Get action links for a log row.
*
* @param object $row Log row object.
* @return array Array of action link arrays.
*/
public function get_action_links( $row ) {
$context = $row->context;
$message_key = $context['_message_key'] ?? '';
$action_links = [];
// Per-user edit link for messages that target a specific user.
$user_id = 0;
if ( $message_key === 'user_updated_profile' ) {
$user_id = isset( $context['edited_user_id'] ) ? (int) $context['edited_user_id'] : 0;
} elseif ( $message_key === 'user_created' ) {
$user_id = isset( $context['created_user_id'] ) ? (int) $context['created_user_id'] : 0;
}
if ( $user_id && get_userdata( $user_id ) && current_user_can( 'edit_user', $user_id ) ) {
$action_links[] = [
'url' => get_edit_user_link( $user_id ),
'label' => __( 'Edit user', 'simple-history' ),
'action' => 'edit',
];
}
// Overview link only for user-management events, not auth events like login/logout.
$user_management_message_keys = [ 'user_updated_profile', 'user_created', 'user_deleted' ];
if ( in_array( $message_key, $user_management_message_keys, true ) && current_user_can( 'list_users' ) ) {
$action_links[] = [
'url' => admin_url( 'users.php' ),
'label' => __( 'All users', 'simple-history' ),
'action' => 'view',
];
}
return $action_links;
}
/**
* @param object $row Log row.
* @return Event_Details_Group|string
*/
public function get_log_row_details_output( $row ) {
$context = $row->context;
$message_key = $context['_message_key'];
$group = new Event_Details_Group();
if ( $message_key === 'user_updated_profile' ) {
$arr_user_keys_to_show_diff_for = array(
'rich_editing' => array(
'title' => _x( 'Visual editor', 'User logger', 'simple-history' ),
'type' => 'checkbox',
'value_true' => _x( 'Enable', 'User logger', 'simple-history' ),
'value_false' => _x( 'Disable', 'User logger', 'simple-history' ),
),
'admin_color' => array(
'title' => _x( 'Colour scheme', 'User logger', 'simple-history' ),
),
'comment_shortcuts' => array(
'title' => _x( 'Keyboard shortcuts', 'User logger', 'simple-history' ),
'type' => 'checkbox',
'value_true' => _x( 'Enable', 'User logger', 'simple-history' ),
'value_false' => _x( 'Disable', 'User logger', 'simple-history' ),
),
'show_admin_bar_front' => array(
'title' => _x( 'Toolbar', 'User logger', 'simple-history' ),
'type' => 'checkbox',
'value_true' => _x( 'Show', 'User logger', 'simple-history' ),
'value_false' => _x( "Don't show", 'User logger', 'simple-history' ),
),
'locale' => array(
'title' => _x( 'Language', 'User logger', 'simple-history' ),
),
'first_name' => array(
'title' => _x( 'First name', 'User logger', 'simple-history' ),
),
'last_name' => array(
'title' => _x( 'Last name', 'User logger', 'simple-history' ),
),
'nickname' => array(
'title' => _x( 'Nickname', 'User logger', 'simple-history' ),
),
'display_name' => array(
'title' => _x( 'Display name', 'User logger', 'simple-history' ),
),
'user_email' => array(
'title' => _x( 'Email', 'User logger', 'simple-history' ),
),
'user_url' => array(
'title' => _x( 'Website', 'User logger', 'simple-history' ),
),
'description' => array(
'title' => _x( 'Description', 'User logger', 'simple-history' ),
),
'aim' => array(
'title' => _x( 'AIM', 'User logger', 'simple-history' ),
),
'yim' => array(
'title' => _x( 'Yahoo IM', 'User logger', 'simple-history' ),
),
'jabber' => array(
'title' => _x( 'Jabber / Google Talk ', 'User logger', 'simple-history' ),
),
);
require_once ABSPATH . 'wp-admin/includes/translation-install.php';
$translations = wp_get_available_translations();
if ( ! isset( $translations['en_US'] ) ) {
$translations['en_US'] = array(
'language' => 'en_US',
'english_name' => 'English',
);
}
foreach ( $arr_user_keys_to_show_diff_for as $key => $val ) {
if ( ! isset( $context[ "user_prev_{$key}" ] ) || ! isset( $context[ "user_new_{$key}" ] ) ) {
continue;
}
$user_old_value = $context[ "user_prev_{$key}" ];
$user_new_value = $context[ "user_new_{$key}" ];
// Transform locale codes to display names.
if ( $key === 'locale' ) {
if ( isset( $translations[ $user_old_value ] ) ) {
$user_old_value = "{$translations[ $user_old_value ]['english_name']} ({$user_old_value})";
} elseif ( $user_old_value === 'SITE_DEFAULT' ) {
$user_old_value = __( 'Site Default', 'simple-history' );
}
if ( isset( $translations[ $user_new_value ] ) ) {
$user_new_value = "{$translations[ $user_new_value ]['english_name']} ({$user_new_value})";
} elseif ( $user_new_value === 'SITE_DEFAULT' ) {
$user_new_value = __( 'Site Default', 'simple-history' );
}
}
// Transform checkbox values to user-friendly labels.
if ( isset( $val['type'] ) && $val['type'] === 'checkbox' ) {
$user_old_value = $user_old_value === 'true' ? $val['value_true'] : $val['value_false'];
$user_new_value = $user_new_value === 'true' ? $val['value_true'] : $val['value_false'];
}
$group->add_item(
( new Event_Details_Item( null, $val['title'] ) )
->set_values( $user_new_value, $user_old_value )
);
}
// Password changed.
if ( isset( $context['edited_user_password_changed'] ) ) {
$group->add_item(
( new Event_Details_Item( null, _x( 'Password', 'User logger', 'simple-history' ) ) )
->set_new_value( _x( 'Changed', 'User logger', 'simple-history' ) )
);
}
} elseif ( $message_key === 'user_created' ) {
$arr_user_keys_to_show = array(
'created_user_role' => _x( 'Role', 'User logger', 'simple-history' ),
'created_user_first_name' => _x( 'First name', 'User logger', 'simple-history' ),
'created_user_last_name' => _x( 'Last name', 'User logger', 'simple-history' ),
'created_user_url' => _x( 'Website', 'User logger', 'simple-history' ),
'send_user_notification' => _x( 'Send notification', 'User logger', 'simple-history' ),
);
foreach ( $arr_user_keys_to_show as $key => $title ) {
if ( ! isset( $context[ $key ] ) || ! trim( $context[ $key ] ) ) {
continue;
}
$value = $context[ $key ];
if ( $key === 'send_user_notification' ) {
if ( (int) $value !== 1 ) {
continue;
}
$value = _x( 'Checked', 'User logger', 'simple-history' );
}
$group->add_item(
( new Event_Details_Item( null, $title ) )
->set_new_value( $value )
);
}
}
// Common for both modified and added users.
if ( isset( $context['user_added_roles'] ) ) {
$group->add_item(
( new Event_Details_Item( 'user_added_roles', __( 'Role added', 'simple-history' ) ) )
);
}
if ( isset( $context['user_removed_roles'] ) ) {
$group->add_item(
( new Event_Details_Item( 'user_removed_roles', __( 'Role removed', 'simple-history' ) ) )
);
}
if ( empty( $group->items ) ) {
return '';
}
return $group;
}
/**
* Fires when a user is denied access to an admin page due to insufficient capabilities.
*/
public function on_admin_page_access_denied() {
$admin_page = '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$admin_page = sanitize_text_field( wp_unslash( $_GET['page'] ?? '' ) );
// Get the current admin page file.
$pagenow = $GLOBALS['pagenow'] ?? '';
// Construct full page path.
$full_page = $pagenow;
if ( $admin_page ) {
$full_page .= '?page=' . $admin_page;
}
$this->info_message(
'user_admin_page_access_denied',
[
'admin_page' => $full_page,
]
);
}
}