roles, self::PRIVILEGED_ROLES ) ); $severity = $is_privileged ? Login_Audit_Entity::SEVERITY_MEDIUM : Login_Audit_Entity::SEVERITY_LOW; $ip_hash = Rate_Limiter::get_ip_hash(); $failed_count = self::get_cached_failed_count( $ip_hash, 30 ); // Escalate severity if there were recent failed attempts from this IP. if ( $failed_count >= 3 ) { $severity = Login_Audit_Entity::SEVERITY_HIGH; } Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_LOGIN_SUCCESS, $severity, $user->ID, $user_login, $ip_hash, sprintf( /* translators: %1$s: username, %2$d: user ID */ \esc_html__( 'User logged in: %1$s (#%2$d)', '0-day-analytics' ), $user_login, $user->ID ), array( 'roles' => $user->roles, 'prior_failed_attempts' => $failed_count, ) ); } /** * Log failed login attempt. * * @param string $username Username attempted. * @param \WP_Error $error Error object. * * @return void * * @since 5.3.0 */ public static function on_login_failed( string $username, \WP_Error $error ): void { // Expired-session errors are not real login failures — they occur // when the auth cookie outlives the session (e.g. after logout). if ( \in_array( 'expired_session', $error->get_error_codes(), true ) ) { return; } $ip_hash = Rate_Limiter::get_ip_hash(); $failed_count = self::get_cached_failed_count( $ip_hash, 5 ); // Severity escalates with attempt count. $severity = Login_Audit_Entity::SEVERITY_MEDIUM; if ( $failed_count >= 10 ) { $severity = Login_Audit_Entity::SEVERITY_CRITICAL; } elseif ( $failed_count >= 5 ) { $severity = Login_Audit_Entity::SEVERITY_HIGH; } // Resolve user ID if the username exists. $user = \get_user_by( 'login', $username ); $user_id = $user instanceof \WP_User ? $user->ID : 0; // Check if this is an attempt against a non-existent user. $user_exists = ( 0 !== $user_id ); if ( ! $user_exists ) { $user = \get_user_by( 'email', $username ); if ( $user instanceof \WP_User ) { $user_id = $user->ID; $user_exists = true; } } Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_LOGIN_FAILED, $severity, $user_id, $username, $ip_hash, sprintf( /* translators: %s: username */ \esc_html__( 'Failed login attempt for: %s', '0-day-analytics' ), $username ), array( 'error_codes' => $error->get_error_codes(), 'user_exists' => $user_exists, 'attempts_from_ip' => $failed_count + 1, ) ); } /** * Log user logout. * * @param int $user_id User ID. * * @return void * * @since 5.3.0 */ public static function on_logout( int $user_id ): void { $user = \get_userdata( $user_id ); $username = $user instanceof \WP_User ? $user->user_login : 'unknown'; Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_LOGOUT, Login_Audit_Entity::SEVERITY_LOW, $user_id, $username, Rate_Limiter::get_ip_hash(), sprintf( /* translators: %1$s: username, %2$d: user ID */ \esc_html__( 'User logged out: %1$s (#%2$d)', '0-day-analytics' ), $username, $user_id ), array() ); } /** * Log password reset completion. * * @param \WP_User $user User object. * @param string $new_pass New password (not stored). * * @return void * * @since 5.3.0 */ public static function on_password_reset( \WP_User $user, string $new_pass ): void { $is_admin_reset = ( \get_current_user_id() !== $user->ID && \get_current_user_id() > 0 ); Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_PASSWORD_RESET, $is_admin_reset ? Login_Audit_Entity::SEVERITY_HIGH : Login_Audit_Entity::SEVERITY_MEDIUM, $user->ID, $user->user_login, Rate_Limiter::get_ip_hash(), $is_admin_reset ? sprintf( /* translators: %1$s: username, %2$d: user ID, %3$d: admin user ID */ \esc_html__( 'Password reset for %1$s (#%2$d) by admin #%3$d', '0-day-analytics' ), $user->user_login, $user->ID, \get_current_user_id() ) : sprintf( /* translators: %1$s: username, %2$d: user ID */ \esc_html__( 'Password reset for %1$s (#%2$d)', '0-day-analytics' ), $user->user_login, $user->ID ), array( 'admin_reset' => $is_admin_reset, 'reset_by' => $is_admin_reset ? \get_current_user_id() : $user->ID, ) ); } /** * Log password reset request (email sent). * * @param string $user_login Username requesting reset. * * @return void * * @since 5.3.0 */ public static function on_password_request( string $user_login ): void { $user = \get_user_by( 'login', $user_login ); $user_id = $user instanceof \WP_User ? $user->ID : 0; Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_PASSWORD_REQUEST, Login_Audit_Entity::SEVERITY_MEDIUM, $user_id, $user_login, Rate_Limiter::get_ip_hash(), sprintf( /* translators: %s: username */ \esc_html__( 'Password reset requested for: %s', '0-day-analytics' ), $user_login ), array() ); } /** * Log bad username in auth cookie. * * @param array $cookie_elements Cookie components. * * @return void * * @since 5.3.0 */ public static function on_cookie_bad_username( array $cookie_elements ): void { $username = isset( $cookie_elements['username'] ) ? \sanitize_user( $cookie_elements['username'] ) : 'unknown'; Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_COOKIE_INVALID, Login_Audit_Entity::SEVERITY_HIGH, 0, $username, Rate_Limiter::get_ip_hash(), sprintf( /* translators: %s: username */ \esc_html__( 'Invalid auth cookie — bad username: %s', '0-day-analytics' ), $username ), array( 'reason' => 'bad_username' ) ); } /** * Log bad hash in auth cookie. * * @param array $cookie_elements Cookie components. * * @return void * * @since 5.3.0 */ public static function on_cookie_bad_hash( array $cookie_elements ): void { $username = isset( $cookie_elements['username'] ) ? \sanitize_user( $cookie_elements['username'] ) : 'unknown'; Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_COOKIE_INVALID, Login_Audit_Entity::SEVERITY_HIGH, 0, $username, Rate_Limiter::get_ip_hash(), sprintf( /* translators: %s: username */ \esc_html__( 'Invalid auth cookie — bad hash for: %s', '0-day-analytics' ), $username ), array( 'reason' => 'bad_hash' ) ); } /** * Log expired auth cookie (triggers interim login). * * Fires when WordPress detects the auth cookie has expired, * which causes the interim login modal to appear in wp-admin. * * @param array $cookie_elements Cookie components. * * @return void * * @since 5.3.3 */ public static function on_cookie_expired( array $cookie_elements ): void { if ( self::$session_expired_logged ) { return; } self::$session_expired_logged = true; $username = isset( $cookie_elements['username'] ) ? \sanitize_user( $cookie_elements['username'] ) : 'unknown'; $user = \get_user_by( 'login', $username ); $user_id = $user instanceof \WP_User ? $user->ID : 0; // Prevent duplicate logging across requests (e.g. AJAX polling with dead cookies). $transient_key = 'advan_sess_exp_' . ( $user_id ?: md5( $username ) ); if ( \get_transient( $transient_key ) ) { return; } \set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS ); $is_privileged = ( $user instanceof \WP_User ) && ! empty( array_intersect( $user->roles, self::PRIVILEGED_ROLES ) ); $severity = $is_privileged ? Login_Audit_Entity::SEVERITY_MEDIUM : Login_Audit_Entity::SEVERITY_LOW; Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_SESSION_EXPIRED, $severity, $user_id, $username, Rate_Limiter::get_ip_hash(), sprintf( /* translators: %1$s: username, %2$d: user ID */ \esc_html__( 'Session expired (interim login triggered) for: %1$s (#%2$d)', '0-day-analytics' ), $username, $user_id ), array( 'reason' => 'cookie_expired', ) ); } /** * Log session token destruction (e.g., "Log Out Everywhere Else"). * * @param int $user_id User ID whose sessions were destroyed. * * @return void * * @since 5.3.0 */ public static function on_session_destroy( int $user_id ): void { $user = \get_userdata( $user_id ); $username = $user instanceof \WP_User ? $user->user_login : 'unknown'; $is_self = ( \get_current_user_id() === $user_id ); Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_SESSION_DESTROY, $is_self ? Login_Audit_Entity::SEVERITY_LOW : Login_Audit_Entity::SEVERITY_HIGH, $user_id, $username, Rate_Limiter::get_ip_hash(), $is_self ? sprintf( /* translators: %1$s: username, %2$d: user ID */ \esc_html__( 'All sessions destroyed for %1$s (#%2$d)', '0-day-analytics' ), $username, $user_id ) : sprintf( /* translators: %1$s: username, %2$d: user ID, %3$d: admin user ID */ \esc_html__( 'All sessions destroyed for %1$s (#%2$d) by admin #%3$d', '0-day-analytics' ), $username, $user_id, \get_current_user_id() ), array( 'destroyed_by' => \get_current_user_id(), 'is_self' => $is_self, ) ); } /** * Log successful application password authentication. * * @param \WP_User $user Authenticated user. * @param array $app_pass Application password record. * * @return void * * @since 5.3.0 */ public static function on_app_password_auth( \WP_User $user, array $app_pass ): void { $app_name = isset( $app_pass['name'] ) ? \sanitize_text_field( $app_pass['name'] ) : 'unknown'; Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_APP_PASSWORD, Login_Audit_Entity::SEVERITY_MEDIUM, $user->ID, $user->user_login, Rate_Limiter::get_ip_hash(), sprintf( /* translators: %1$s: app name, %2$s: username, %3$d: user ID */ \esc_html__( 'App password auth: %1$s for %2$s (#%3$d)', '0-day-analytics' ), $app_name, $user->user_login, $user->ID ), array( 'app_name' => $app_name, 'app_uuid' => $app_pass['uuid'] ?? '', ) ); } /** * Log failed application password authentication. * * @param \WP_Error $error Error object. * * @return void * * @since 5.3.0 */ public static function on_app_password_failed( \WP_Error $error ): void { Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_APP_PASSWORD, Login_Audit_Entity::SEVERITY_HIGH, 0, '', Rate_Limiter::get_ip_hash(), \esc_html__( 'Failed application password authentication', '0-day-analytics' ), array( 'error_codes' => $error->get_error_codes(), ) ); } /** * Record that an auth cookie was set. * * Fires inside wp_set_auth_cookie(). In a normal login flow, * wp_login fires afterwards and sets $login_logged = true. * If wp_login never fires, on_shutdown_check_cookie_login() * will log the event. * * @param string $logged_in_cookie The logged-in cookie value. * @param int $expire The time the login grace period expires as a UNIX timestamp. * @param int $expiration The time when the logged-in authentication cookie expires as a UNIX timestamp. * @param int $user_id User ID. * @param string $scheme Authentication scheme. * @param string $token User's session token. * * @return void * * @since 5.3.1 */ public static function on_set_logged_in_cookie( string $logged_in_cookie, int $expire, int $expiration, int $user_id, string $scheme, string $token ): void { self::$cookie_login_user_id = $user_id; } /** * On shutdown, log cookie-based logins that bypassed wp_login. * * If set_logged_in_cookie fired but wp_login did not, a plugin * authenticated the user by calling wp_set_auth_cookie() directly. * We log this so the login does not go untracked. * * @return void * * @since 5.3.1 */ public static function on_shutdown_check_cookie_login(): void { if ( null === self::$cookie_login_user_id || self::$login_logged ) { return; } $user = \get_userdata( self::$cookie_login_user_id ); if ( ! $user instanceof \WP_User ) { return; } $is_privileged = ! empty( array_intersect( $user->roles, self::PRIVILEGED_ROLES ) ); $severity = $is_privileged ? Login_Audit_Entity::SEVERITY_HIGH : Login_Audit_Entity::SEVERITY_MEDIUM; $ip_hash = Rate_Limiter::get_ip_hash(); Login_Audit_Entity::log_event( Login_Audit_Entity::EVENT_LOGIN_COOKIE, $severity, $user->ID, $user->user_login, $ip_hash, sprintf( /* translators: %1$s: username, %2$d: user ID */ \esc_html__( 'Cookie-based login (wp_login not fired): %1$s (#%2$d)', '0-day-analytics' ), $user->user_login, $user->ID ), array( 'roles' => $user->roles, 'source' => 'set_logged_in_cookie', ) ); } /** * Get cached failed login count for an IP to avoid DB queries under brute force. * * Caches the result for 30 seconds per IP hash so rapid-fire login attempts * don't trigger a COUNT(*) query on every single request. * * @param string $ip_hash IP hash. * @param int $minutes Lookback window in minutes. * * @return int Failed login count. * * @since 5.3.2 */ private static function get_cached_failed_count( string $ip_hash, int $minutes ): int { if ( '' === $ip_hash ) { return 0; } $cache_key = 'advan_fail_cnt_' . substr( $ip_hash, 0, 12 ) . '_' . $minutes; $cached = \wp_cache_get( $cache_key, 'advan_login_audit' ); if ( false !== $cached ) { return (int) $cached; } $count = Login_Audit_Entity::count_failed_logins_by_ip( $ip_hash, $minutes ); \wp_cache_set( $cache_key, $count, 'advan_login_audit', 30 ); return $count; } } }