ID, self::FORCE_META, true ) ) { return $user; } return new \WP_Error( 'gemsecurity_password_reset_required', sprintf( /* translators: %s: lost-password URL. */ __( 'Password reset required. Your password must be changed before you can sign in. Set a new password — a link was emailed to you.', 'gemsecurity' ), esc_url( wp_lostpassword_url() ) ) ); } /** @param \WP_User $user */ public static function clear_flag_for_user( $user ) { if ( $user instanceof \WP_User ) { delete_user_meta( $user->ID, self::FORCE_META ); update_user_meta( $user->ID, self::SET_META, time() ); } } /** @param int $user_id */ public static function clear_flag_on_profile_update( $user_id ) { // Only an actual new password lifts the flag. if ( ! empty( $_POST['pass1'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing delete_user_meta( $user_id, self::FORCE_META ); update_user_meta( $user_id, self::SET_META, time() ); } } /* ------------------------------------------------------------------ */ /* Forcing a reset */ /* ------------------------------------------------------------------ */ /** * Users a reset would apply to. * * @param array $args roles[], older_than_days, include_user_ids[], exclude_user_ids[] * @return \WP_User[] */ public static function targets( array $args = [] ) { $roles = isset( $args['roles'] ) ? array_filter( (array) $args['roles'] ) : []; $older = isset( $args['older_than_days'] ) ? (int) $args['older_than_days'] : 0; $exclude = isset( $args['exclude_user_ids'] ) ? array_map( 'intval', (array) $args['exclude_user_ids'] ) : []; $query = [ 'fields' => 'all', 'number' => -1 ]; if ( $roles ) { $query['role__in'] = $roles; } $users = get_users( $query ); $out = []; foreach ( $users as $user ) { if ( in_array( (int) $user->ID, $exclude, true ) ) { continue; } // Already flagged — no point counting them twice. if ( get_user_meta( $user->ID, self::FORCE_META, true ) ) { continue; } if ( $older > 0 ) { $set = (int) get_user_meta( $user->ID, self::SET_META, true ); if ( ! $set ) { // Never stamped: start the clock rather than forcing a reset // on a password that may have been set five minutes ago. update_user_meta( $user->ID, self::SET_META, time() ); continue; } if ( ( time() - $set ) < $older * DAY_IN_SECONDS ) { continue; } } $out[] = $user; } return $out; } /** * Force the reset. * * @param array $args roles[], older_than_days, exclude_user_ids[], notify (bool), reason (string) * @return array { count, users[], notified } */ public static function force_reset( array $args = [] ) { $notify = ! isset( $args['notify'] ) || $args['notify']; $reason = isset( $args['reason'] ) ? (string) $args['reason'] : 'manual'; $targets = self::targets( $args ); $done = []; $notified = 0; foreach ( $targets as $user ) { // Kill live sessions first: a reset that leaves the intruder's // cookie working has evicted nobody. $tokens = \WP_Session_Tokens::get_instance( $user->ID ); $tokens->destroy_all(); update_user_meta( $user->ID, self::FORCE_META, time() ); if ( $notify && $user->user_email && self::send_reset_link( $user, $reason ) ) { $notified++; } $done[] = [ 'id' => (int) $user->ID, 'login' => $user->user_login, 'email' => $user->user_email ]; } if ( $done ) { Logger::log( 'passwords_force_reset', sprintf( 'Forced a password reset for %d user(s). Trigger: %s.', count( $done ), $reason ), Logger::SEVERITY_CRITICAL, [ 'context' => [ 'count' => count( $done ), 'reason' => $reason ] ] ); } return [ 'count' => count( $done ), 'users' => $done, 'notified' => $notified ]; } /** * Email a user their own reset link. WordPress's own key/flow is used, so * nothing here ever knows or transmits a password. * * @param \WP_User $user * @param string $reason * @return bool */ private static function send_reset_link( $user, $reason ) { $key = get_password_reset_key( $user ); if ( is_wp_error( $key ) ) { return false; } $link = network_site_url( 'wp-login.php?action=rp&key=' . rawurlencode( $key ) . '&login=' . rawurlencode( $user->user_login ), 'login' ); $why = 'schedule' === $reason ? __( 'Your password has reached the age limit set by this site’s security policy.', 'gemsecurity' ) : __( 'An administrator has required every affected account to choose a new password.', 'gemsecurity' ); return Mailer::send( $user->user_email, '🔒', __( 'Choose a new password to continue', 'gemsecurity' ), [ [ 'type' => 'paragraph', 'text' => esc_html( $why ) ], [ 'type' => 'paragraph', 'text' => __( 'Your current password no longer works and any signed-in sessions have been ended. Use the button below to set a new one.', 'gemsecurity' ), ], [ 'type' => 'button', 'url' => $link, 'label' => __( 'Set a new password', 'gemsecurity' ) ], [ 'type' => 'notice', 'tone' => 'info', 'text' => __( 'If the link has expired, use “Lost your password?” on the sign-in page — it works the same way.', 'gemsecurity' ), ], ] ); } /* ------------------------------------------------------------------ */ /* Schedule */ /* ------------------------------------------------------------------ */ /** Keep the daily rotation check scheduled while the feature is on. */ public static function schedule() { $next = wp_next_scheduled( self::CRON_HOOK ); if ( ! Helper::get_setting( 'password_rotation_enabled', false ) ) { if ( $next ) { wp_unschedule_event( $next, self::CRON_HOOK ); } return; } if ( ! $next ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK ); } } /** * @param mixed $old * @param mixed $new */ public static function reschedule( $old, $new ) { $before = is_array( $old ) && isset( $old['password_rotation_enabled'] ) ? $old['password_rotation_enabled'] : null; $after = is_array( $new ) && isset( $new['password_rotation_enabled'] ) ? $new['password_rotation_enabled'] : null; if ( $before !== $after ) { self::schedule(); } } /** * Daily: flag anyone whose password is older than the configured age. * * @return array */ public static function run_scheduled_rotation() { if ( ! Helper::get_setting( 'password_rotation_enabled', false ) ) { return [ 'count' => 0, 'users' => [], 'notified' => 0 ]; } $days = (int) Helper::get_setting( 'password_rotation_days', 90 ); if ( $days <= 0 ) { return [ 'count' => 0, 'users' => [], 'notified' => 0 ]; } return self::force_reset( [ 'roles' => (array) Helper::get_setting( 'password_rotation_roles', [] ), 'older_than_days' => $days, 'reason' => 'schedule', 'notify' => true, ] ); } /** * Lift the flag from everyone — the way back if a reset was applied too * broadly and users cannot get in. * * @return int Users cleared. */ public static function clear_all() { $users = get_users( [ 'meta_key' => self::FORCE_META, 'fields' => 'ID', 'number' => -1 ] ); // phpcs:ignore WordPress.DB.SlowDBQuery $cleared = 0; foreach ( $users as $id ) { delete_user_meta( (int) $id, self::FORCE_META ); $cleared++; } if ( $cleared ) { Logger::log( 'passwords_force_reset_cleared', sprintf( 'Cleared the forced-reset flag for %d user(s).', $cleared ), Logger::SEVERITY_WARNING ); } return $cleared; } /** How many accounts are currently waiting to choose a new password. */ public static function pending_count() { return count( get_users( [ 'meta_key' => self::FORCE_META, 'fields' => 'ID', 'number' => -1 ] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery } }