__( 'Logged in', 'siteaudithub' ), 'failed' => __( 'Failed login', 'siteaudithub' ), 'logout' => __( 'Logged out', 'siteaudithub' ), ); } /** * Successful login. * * @param string $user_login Login. * @param \WP_User $user User. */ public static function on_login( $user_login, $user = null ) { $id = $user instanceof \WP_User ? (int) $user->ID : 0; self::record( 'login', $id, $user instanceof \WP_User ? $user->user_login : (string) $user_login ); } /** * Failed login. Only existing account names are stored. * * @param string $username Submitted username or email. */ public static function on_failed( $username ) { $username = (string) $username; $user = get_user_by( 'login', $username ); if ( ! $user && is_email( $username ) ) { $user = get_user_by( 'email', $username ); } if ( $user ) { self::record( 'failed', (int) $user->ID, $user->user_login ); } else { self::record( 'failed', 0, '' ); } } /** * Logout. * * @param int $user_id User ID (WP 5.5+ passes it). */ public static function on_logout( $user_id = 0 ) { $user_id = (int) $user_id; if ( ! $user_id ) { $user_id = get_current_user_id(); } $user = $user_id ? get_userdata( $user_id ) : null; if ( $user ) { self::record( 'logout', $user_id, $user->user_login ); } } /** * Insert a row (never throws; logging must not break logins). * * @param string $event Event. * @param int $user_id User. * @param string $login Login name ('' = unknown). */ public static function record( $event, $user_id, $login ) { global $wpdb; if ( ! in_array( $event, self::EVENTS, true ) || ! Db::table_exists( 'logins' ) ) { return; } $wpdb->insert( Db::table( 'logins' ), array( 'user_id' => (int) $user_id, 'user_login' => mb_substr( sanitize_user( (string) $login, false ), 0, 60 ), 'event' => $event, 'device' => self::device(), 'created_at' => Db::now(), ) ); } /** * Short browser/OS label from the user agent (the raw string is not stored). * * @param string|null $ua User agent. * @return string */ public static function device( $ua = null ) { if ( null === $ua ) { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; } if ( '' === $ua ) { return ''; } $browsers = array( 'Edge' => '/Edg(e|A|iOS)?\//', 'Opera' => '/OPR\/|Opera/', 'Samsung Internet' => '/SamsungBrowser/', 'Firefox' => '/Firefox\/|FxiOS/', 'Chrome' => '/Chrome\/|CriOS/', 'Safari' => '/Safari\//', ); $systems = array( 'Android' => '/Android/', 'iPhone' => '/iPhone/', 'iPad' => '/iPad/', 'Windows' => '/Windows/', 'macOS' => '/Mac OS X|Macintosh/', 'Linux' => '/Linux/', ); $browser = __( 'Other browser', 'siteaudithub' ); foreach ( $browsers as $name => $re ) { if ( preg_match( $re, $ua ) ) { $browser = $name; break; } } $os = ''; foreach ( $systems as $name => $re ) { if ( preg_match( $re, $ua ) ) { $os = $name; break; } } if ( preg_match( '/curl|wget|python|bot|spider|http/i', $ua ) && __( 'Other browser', 'siteaudithub' ) === $browser ) { $browser = __( 'Script / bot', 'siteaudithub' ); } /* translators: 1: browser, 2: operating system */ return mb_substr( $os ? sprintf( __( '%1$s on %2$s', 'siteaudithub' ), $browser, $os ) : $browser, 0, 80 ); } /** * Paginated query. * * @param array $args event, search, page, per_page. * @return array [rows, total] */ public static function query( array $args ) { global $wpdb; $table = Db::table( 'logins' ); if ( ! Db::table_exists( 'logins' ) ) { return array( array(), 0 ); } $event = ( ! empty( $args['event'] ) && in_array( $args['event'], self::EVENTS, true ) ) ? (string) $args['event'] : ''; $search = empty( $args['search'] ) ? '' : '%' . $wpdb->esc_like( (string) $args['search'] ) . '%'; $since = empty( $args['days'] ) ? '' : gmdate( 'Y-m-d H:i:s', time() - (int) $args['days'] * DAY_IN_SECONDS ); $per = max( 1, min( 500, (int) ( isset( $args['per_page'] ) ? $args['per_page'] : 30 ) ) ); $page = max( 1, (int) ( isset( $args['page'] ) ? $args['page'] : 1 ) ); $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE ( %s = '' OR event = %s ) AND ( %s = '' OR user_login LIKE %s ) AND ( %s = '' OR created_at >= %s )", $table, $event, $event, $search, $search, $since, $since ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = '' OR event = %s ) AND ( %s = '' OR user_login LIKE %s ) AND ( %s = '' OR created_at >= %s ) ORDER BY id DESC LIMIT %d OFFSET %d", $table, $event, $event, $search, $search, $since, $since, $per, ( $page - 1 ) * $per ) ); return array( (array) $rows, $total ); } /** * Summary for the last N days. * * @param int $days Days. * @return array */ public static function stats( $days = 7 ) { global $wpdb; $out = array( 'login' => 0, 'failed' => 0, 'logout' => 0, 'users' => 0, ); if ( ! Db::table_exists( 'logins' ) ) { return $out; } $table = Db::table( 'logins' ); $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT event, COUNT(*) AS c FROM %i WHERE created_at >= %s GROUP BY event', $table, $since ) ) as $row ) { $out[ $row->event ] = (int) $row->c; } $out['users'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT user_id) FROM %i WHERE created_at >= %s AND event = 'login'", $table, $since ) ); return $out; } /** * Delete everything. */ public static function clear() { global $wpdb; if ( Db::table_exists( 'logins' ) ) { $table = Db::table( 'logins' ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', $table ) ); } } /** * Retention: drop rows older than the configured days and cap total rows. */ public static function prune() { global $wpdb; if ( ! Db::table_exists( 'logins' ) ) { return; } $table = Db::table( 'logins' ); $days = max( 1, (int) Settings::get( 'login_log_days' ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', $table, gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); $cutoff = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i ORDER BY id DESC LIMIT 1 OFFSET %d', $table, self::MAX_ROWS ) ); if ( $cutoff ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id <= %d', $table, (int) $cutoff ) ); } } /** * Suggested privacy policy text. */ public static function privacy_text() { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'SiteAuditHub', '
' . esc_html__( 'When you log in or out of this website, or a login to your account fails, we record your username, the date and time, and your browser and device type (for example "Chrome on Windows") to help keep accounts secure. IP addresses and locations are not recorded. These records are kept on this website only and deleted automatically after the configured retention period.', 'siteaudithub' ) . '
' ); } } /** * Register personal data exporter. * * @param array $exporters Exporters. * @return array */ public static function register_exporter( $exporters ) { $exporters['siteaudithub-logins'] = array( 'exporter_friendly_name' => __( 'SiteAuditHub login activity', 'siteaudithub' ), 'callback' => array( __CLASS__, 'export_personal_data' ), ); return $exporters; } /** * Register personal data eraser. * * @param array $erasers Erasers. * @return array */ public static function register_eraser( $erasers ) { $erasers['siteaudithub-logins'] = array( 'eraser_friendly_name' => __( 'SiteAuditHub login activity', 'siteaudithub' ), 'callback' => array( __CLASS__, 'erase_personal_data' ), ); return $erasers; } /** * Export a user's login activity. * * @param string $email Email. * @param int $page Page. * @return array */ public static function export_personal_data( $email, $page = 1 ) { global $wpdb; $user = get_user_by( 'email', $email ); $data = array(); $done = true; if ( $user && Db::table_exists( 'logins' ) ) { $table = Db::table( 'logins' ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE user_id = %d ORDER BY id ASC LIMIT 200 OFFSET %d', $table, $user->ID, ( max( 1, (int) $page ) - 1 ) * 200 ) ); $labels = self::labels(); foreach ( (array) $rows as $row ) { $data[] = array( 'group_id' => 'siteaudithub-logins', 'group_label' => __( 'Login activity', 'siteaudithub' ), 'item_id' => 'sah-login-' . $row->id, 'data' => array( array( 'name' => __( 'Event', 'siteaudithub' ), 'value' => isset( $labels[ $row->event ] ) ? $labels[ $row->event ] : $row->event, ), array( 'name' => __( 'Date (UTC)', 'siteaudithub' ), 'value' => $row->created_at, ), array( 'name' => __( 'Browser / device', 'siteaudithub' ), 'value' => $row->device, ), ), ); } $done = count( (array) $rows ) < 200; } return array( 'data' => $data, 'done' => $done, ); } /** * Erase a user's login activity. * * @param string $email Email. * @param int $page Page. * @return array */ public static function erase_personal_data( $email, $page = 1 ) { global $wpdb; unset( $page ); $user = get_user_by( 'email', $email ); $removed = 0; if ( $user && Db::table_exists( 'logins' ) ) { $removed = (int) $wpdb->delete( Db::table( 'logins' ), array( 'user_id' => (int) $user->ID ) ); } return array( 'items_removed' => $removed > 0, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } }