how the row names them. Only * `wp_users` columns: `profile_update` hands over the user as they were, * and user meta (first/last name, nickname, bio) is not in it -- a named * deferral (docs/event-coverage.md). */ private const PROFILE_FIELDS = array( 'user_pass' => 'password', 'user_email' => 'email', 'display_name' => 'display_name', 'user_url' => 'website', 'user_nicename' => 'nicename', ); /** * @return array Event type => label for the log's filter. */ public static function event_types(): array { return array( self::PROFILE_UPDATED => 'Profile Updated', self::EMAIL_CHANGED => 'Email Address Changed', self::PASSWORD_CHANGED => 'Password Changed', self::PASSWORD_RESET => 'Password Reset', self::APP_PASSWORD_NEW => 'Application Password Created', self::APP_PASSWORD_DEL => 'Application Password Revoked', ); } public static function register(): void { add_action( 'profile_update', array( __CLASS__, 'log_profile_update' ), 10, 2 ); add_action( 'after_password_reset', array( __CLASS__, 'log_password_reset' ) ); add_action( 'wp_create_application_password', array( __CLASS__, 'log_app_password_created' ), 10, 2 ); add_action( 'wp_delete_application_password', array( __CLASS__, 'log_app_password_revoked' ), 10, 2 ); } /** * Which tracked fields differ between the user as they were and as they * are, by the names the row uses. * * @param object $before The user before the save. * @param object $after The user after it. * @return string[] */ public static function changed_fields( object $before, object $after ): array { $changed = array(); foreach ( self::PROFILE_FIELDS as $field => $name ) { $old = isset( $before->$field ) && is_scalar( $before->$field ) ? (string) $before->$field : ''; $new = isset( $after->$field ) && is_scalar( $after->$field ) ? (string) $after->$field : ''; if ( $old !== $new ) { $changed[] = $name; } } return $changed; } /** * The row type a set of changed fields earns: the most serious one. * * @param string[] $fields Changed field names. */ public static function type_for( array $fields ): string { if ( in_array( 'password', $fields, true ) ) { return self::PASSWORD_CHANGED; } return in_array( 'email', $fields, true ) ? self::EMAIL_CHANGED : self::PROFILE_UPDATED; } /** * `profile_update`. * * @param int $user_id The user. * @param mixed $old_user_data The user before the save (a WP_User). */ public static function log_profile_update( int $user_id, $old_user_data ): void { if ( ! is_object( $old_user_data ) ) { return; } $user = get_userdata( $user_id ); if ( ! $user ) { return; } $before = isset( $old_user_data->data ) && is_object( $old_user_data->data ) ? $old_user_data->data : $old_user_data; $fields = self::changed_fields( $before, $user->data ); if ( array() === $fields ) { return; } $type = self::type_for( $fields ); $verb = array( self::PASSWORD_CHANGED => 'User %s password changed', self::EMAIL_CHANGED => 'User %s email address changed', self::PROFILE_UPDATED => 'User %s profile updated', ); EventLogger::log( $type, sprintf( $verb[ $type ], $user->user_login ), 'user', $user_id, array( 'fields' => $fields ) ); } /** * `after_password_reset`. Core passes the new password as a second * argument; this listener is registered for one argument and never sees * it. * * Attributed to whoever is signed in, else -- the lost-password link, used * signed out -- to the user themselves: only the holder of the link * emailed to them can complete it, the same reasoning that attributes a * login to the user who logged in. * * @param mixed $user The user whose password was reset (a WP_User). */ public static function log_password_reset( $user ): void { if ( ! $user instanceof \WP_User || $user->ID <= 0 ) { return; } $actor = get_current_user_id() > 0 ? null : (int) $user->ID; EventLogger::log( self::PASSWORD_RESET, sprintf( 'User %s password reset', $user->user_login ), 'user', (int) $user->ID, null, $actor ); } /** * `wp_create_application_password`. Core passes the new plain-text * password as a third argument; this listener is registered for two and * never sees it. The stored item carries its hash, which is not copied. * * @param int $user_id The account the password is for. * @param mixed $item The stored item: uuid, name, password (hash), ... */ public static function log_app_password_created( int $user_id, $item ): void { self::log_app_password( self::APP_PASSWORD_NEW, 'Application password "%1$s" created for %2$s', $user_id, $item ); } /** * `wp_delete_application_password`. * * @param int $user_id The account the password was for. * @param mixed $item The removed item. */ public static function log_app_password_revoked( int $user_id, $item ): void { self::log_app_password( self::APP_PASSWORD_DEL, 'Application password "%1$s" revoked for %2$s', $user_id, $item ); } /** * @param string $type Event type. * @param string $summary Summary format: name, then login. * @param int $user_id The account. * @param mixed $item The item core passed. */ private static function log_app_password( string $type, string $summary, int $user_id, $item ): void { $item = is_array( $item ) ? $item : array(); $user = get_userdata( $user_id ); $name = EventLogger::bounded( isset( $item['name'] ) && is_string( $item['name'] ) ? $item['name'] : '', self::APP_NAME_LENGTH ); EventLogger::log( $type, sprintf( $summary, $name, $user ? $user->user_login : (string) $user_id ), 'user', $user_id, array( // Only the two identifying fields; never `password` (a hash). 'uuid' => isset( $item['uuid'] ) && is_string( $item['uuid'] ) ? EventLogger::bounded( $item['uuid'], 36 ) : null, 'app_name' => $name, ) ); } }