: str_replace(): Passing null to parameter #2 ($replace) of type array|string is deprecated in
use WordfenceLS\Crypto\Model_JWT;
use WordfenceLS\Crypto\Model_Symmetric;
const RECOVERY_CODE_COUNT = 5;
const RECOVERY_CODE_SIZE = 8;
const SECONDS_PER_DAY = 86400;
const META_KEY_GRACE_PERIOD_RESET = 'wfls-grace-period-reset';
const META_KEY_GRACE_PERIOD_OVERRIDE = 'wfls-grace-period-override';
const META_KEY_ALLOW_GRACE_PERIOD = 'wfls-allow-grace-period';
const META_KEY_VERIFICATION_TOKENS = 'wfls-verification-tokens';
const META_KEY_CAPTCHA_SCORES = 'wfls-captcha-scores';
const VERIFICATION_TOKEN_BYTES = 64;
const VERIFICATION_TOKEN_LIMIT = 5; //Max number of concurrent tokens
const VERIFICATION_TOKEN_TRANSIENT_PREFIX = 'wfls_verify_';
const CAPTCHA_SCORE_LIMIT = 2; //Max number of captcha scores cached
const CAPTCHA_SCORE_TRANSIENT_PREFIX = 'wfls_captcha_';
const CAPTCHA_SCORE_CACHE_DURATION = 60; //seconds
const LARGE_USER_BASE_THRESHOLD = 1000;
const TRUNCATED_ROLE_KEY = 1;
* Returns the singleton Controller_Users.
* @return Controller_Users
public static function shared() {
$_shared = new Controller_Users();
* Imports the array of 2FA secrets. Users that do not currently exist or are disallowed from enabling 2FA are not imported.
* @param array $secrets An array of secrets in the format array(<user id> => array('secret' => <secret in hex>, 'recovery' => <recovery keys in hex>, 'ctime' => <timestamp>, 'vtime' => <timestamp>, 'type' => <type>), ...)
* @return int The number imported.
public function import_2fa($secrets) {
$table = Controller_DB::shared()->secrets;
foreach ($secrets as $id => $parameters) {
$user = new \WP_User($id);
if (!$user->exists() || !$this->can_activate_2fa($user) || $parameters['type'] != 'authenticator' || $this->has_2fa_active($user)) { continue; }
$secret = Model_Compat::hex2bin($parameters['secret']);
$recovery = Model_Compat::hex2bin($parameters['recovery']);
$ctime = (int) $parameters['ctime'];
$vtime = min((int) $parameters['vtime'], Controller_Time::time());
$type = $parameters['type'];
$wpdb->query($wpdb->prepare("INSERT INTO `{$table}` (`user_id`, `secret`, `recovery`, `ctime`, `vtime`, `mode`) VALUES (%d, %s, %s, %d, %d, %s)", $user->ID, $secret, $recovery, $ctime, $vtime, $type));
public function admin_users() {
//We should eventually allow for any user to be granted the manage capability, but we won't account for that now
$logins = get_super_admins();
foreach ($logins as $l) {
$user = new \WP_User(null, $l);
$query = new \WP_User_Query(http_build_query(array('role' => 'administrator', 'number' => -1)));
return $query->get_results();
public function get_users_by_role($role, $limit = -1) {
if ($role === 'super-admin') {
foreach(get_super_admins() as $username) {
$superAdmins[] = new \WP_User($username);
$query = new \WP_User_Query(http_build_query(array('role' => $role, 'number' => is_int($limit) ? $limit : -1)));
return $query->get_results();
* Returns whether or not the user has a valid remembered device.
public function has_remembered_2fa($user) {
static $_cache = array();
if (isset($_cache[$user->ID])) {
return $_cache[$user->ID];
if (!Controller_Settings::shared()->get_bool(Controller_Settings::OPTION_REMEMBER_DEVICE_ENABLED)) {
$maxExpiration = \WordfenceLS\Controller_Time::time() + Controller_Settings::shared()->get_int(Controller_Settings::OPTION_REMEMBER_DEVICE_DURATION);
$encrypted = Model_Symmetric::encrypt((string) $user->ID);
if (!$encrypted) { //Can't generate cookie key due to host failure
foreach ($_COOKIE as $name => $value) {
if (!preg_match('/^wfls\-remembered\-(.+)$/', $name, $matches)) {
$jwt = Model_JWT::decode_jwt($value);
if (!$jwt || !isset($jwt->payload['iv'])) {
if (\WordfenceLS\Controller_Time::time() > min($jwt->expiration, $maxExpiration)) { //Either JWT is expired or the remember period was shortened since generating it
$data = Model_JWT::base64url_convert_from($matches[1]);
$iv = $jwt->payload['iv'];
$encrypted = array('data' => $data, 'iv' => $iv);
$userID = (int) Model_Symmetric::decrypt($encrypted);
if ($userID != 0 && $userID == $user->ID) {
$_cache[$user->ID] = true;
$_cache[$user->ID] = false;
* Sets the cookie needed to remember the 2FA status.
public function remember_2fa($user) {
if (!Controller_Settings::shared()->get_bool(Controller_Settings::OPTION_REMEMBER_DEVICE_ENABLED)) {
if ($this->has_remembered_2fa($user)) {
$encrypted = Model_Symmetric::encrypt((string) $user->ID);
if (!$encrypted) { //Can't generate cookie key due to host failure
foreach ($_COOKIE as $name => $value) {
if (!preg_match('/^wfls\-remembered\-(.+)$/', $name, $matches)) {
setcookie($name, '', \WordfenceLS\Controller_Time::time() - 86400);
$expiration = \WordfenceLS\Controller_Time::time() + Controller_Settings::shared()->get_int(Controller_Settings::OPTION_REMEMBER_DEVICE_DURATION);
$jwt = new Model_JWT(array('iv' => $encrypted['iv']), $expiration);
$cookieName = 'wfls-remembered-' . Model_JWT::base64url_convert_to($encrypted['data']);
$cookieValue = (string) $jwt;
setcookie($cookieName, $cookieValue, $expiration, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true);
* Returns whether or not 2FA can be activated on the given user.
public function can_activate_2fa($user) {
if (is_multisite() && !is_super_admin($user->ID)) {
return Controller_Permissions::shared()->does_user_have_multisite_capability($user, Controller_Permissions::CAP_ACTIVATE_2FA_SELF);
return user_can($user, Controller_Permissions::CAP_ACTIVATE_2FA_SELF);
* Returns whether or not any user has 2FA activated.
public function any_2fa_active() {
$table = Controller_DB::shared()->secrets;
return !!intval($wpdb->get_var("SELECT COUNT(*) FROM `{$table}`"));
* Returns whether or not the user has 2FA activated.
public function has_2fa_active($user) {
$table = Controller_DB::shared()->secrets;
return $this->can_activate_2fa($user) && !!intval($wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM `{$table}` WHERE `user_id` = %d", $user->ID)));
public function deactivate_2fa($user) {
$table = Controller_DB::shared()->secrets;
$wpdb->query($wpdb->prepare("DELETE FROM `{$table}` WHERE `user_id` = %d", $user->ID));
private function has_admin_with_2fa_active() {
$activeIDs = $this->_user_ids_with_2fa_active();
foreach ($activeIDs as $id) {
if (Controller_Permissions::shared()->can_manage_settings(new \WP_User($id))) {
* Returns whether or not 2FA is required for the user regardless of activation status. 2FA is considered required
* when the option to require it is enabled and there is at least one administrator with it active.
* @param bool &$gracePeriod
* @param int &$requiredAt
public function requires_2fa($user, &$gracePeriod = false, &$requiredAt = null) {
if (array_key_exists($user->ID, $cache)) {
list($required, $gracePeriod, $requiredAt) = $cache[$user->ID];
$required = $this->does_user_role_require_2fa($user, $gracePeriod, $requiredAt);
$cache[$user->ID] = array($required, $gracePeriod, $requiredAt);
* Returns the number of recovery codes remaining for the user or null if the user does not have 2FA active.
public function recovery_code_count($user) {
$table = Controller_DB::shared()->secrets;
$record = $wpdb->get_var($wpdb->prepare("SELECT `recovery` FROM `{$table}` WHERE `user_id` = %d", $user->ID));
return floor(Model_Crypto::strlen($record) / self::RECOVERY_CODE_SIZE);
* Generates a new set of recovery codes and saves them to $user if provided.
* @param \WP_User|bool $user The user to save the codes to or false to just return codes.
public function regenerate_recovery_codes($user = false, $count = self::RECOVERY_CODE_COUNT) {
for ($i = 0; $i < $count; $i++) {
$c = \WordfenceLS\Model_Crypto::random_bytes(self::RECOVERY_CODE_SIZE);
if ($user && Controller_Users::shared()->has_2fa_active($user)) {
$table = Controller_DB::shared()->secrets;
$wpdb->query($wpdb->prepare("UPDATE `{$table}` SET `recovery` = %s WHERE `user_id` = %d", implode('', $codes), $user->ID));
* Records the reCAPTCHA score for later display.
* This is not atomic, which means this can miscount on hits that overlap, but the overhead of being atomic is not
* @param \WP_User $user|null
public function record_captcha_score($user, $score) {
if (!Controller_CAPTCHA::shared()->enabled()) { return; }
if ($user) { update_user_meta($user->ID, 'wfls-last-captcha-score', $score); }
$stats = Controller_Settings::shared()->get_array(Controller_Settings::OPTION_CAPTCHA_STATS);
$int_score = min(max((int) ($score * 10), 0), 10);
$count = array_sum($stats['counts']);
$stats['counts'][$int_score]++;
$stats['avg'] = ($stats['avg'] * $count + $int_score) / ($count + 1);
Controller_Settings::shared()->set_array(Controller_Settings::OPTION_CAPTCHA_STATS, $stats);
* Returns the active and inactive user counts.
public function user_counts() {
if (is_multisite() && function_exists('get_user_count')) {
$total_users = get_user_count();
$total_users = (int) $wpdb->get_var("SELECT COUNT(ID) as c FROM {$wpdb->users}");
$active_users = $this->active_count();
return array('active_users' => $active_users, 'inactive_users' => max($total_users - $active_users, 0));
public function detailed_user_counts($force = false) {
$blog_prefix = $wpdb->get_blog_prefix();
$wp_roles = new \WP_Roles();
$roles = $wp_roles->get_names();
$groups = array('avail_roles' => 0, 'active_avail_roles' => 0);
foreach ($groups as $group => $count) {
$counts[$group] = array();
foreach ($roles as $role_key => $role_name) {
$counts[$group][$role_key] = 0;
$counts[$group][self::TRUNCATED_ROLE_KEY] = 0;
$dbController = Controller_DB::shared();
if ($dbController->create_temporary_role_counts_table()) {
$lock = new Utility_NullLock();
$role_counts_table = $dbController->role_counts_temporary;
$lock = new Utility_DatabaseLock($dbController, 'role-count-calculation');
$role_counts_table = $dbController->role_counts;
if(!$force && Controller_Settings::shared()->get_bool(Controller_Settings::OPTION_USER_COUNT_QUERY_STATE))
throw new RuntimeException('Previous user count query failed to completed successfully. User count queries are currently disabled');
Controller_Settings::shared()->set(Controller_Settings::OPTION_USER_COUNT_QUERY_STATE, true);
$dbController->require_schema_version(2);
$secrets_table = $dbController->secrets;
$dbController->query("TRUNCATE {$role_counts_table}");
$dbController->query($wpdb->prepare(<<<SQL
INSERT INTO {$role_counts_table}
um.meta_value AS serialized_roles,
s.user_id IS NULL AS two_factor_inactive,
INNER JOIN {$wpdb->users} u ON u.ID = um.user_id
LEFT JOIN {$secrets_table} s ON s.user_id = u.ID
UPDATE user_count = user_count + 1;
, "{$blog_prefix}capabilities"));
$results = $wpdb->get_results(<<<SQL
serialized_roles AS serialized_roles,
Controller_Settings::shared()->set(Controller_Settings::OPTION_USER_COUNT_QUERY_STATE, false);
catch (RuntimeException $e) {
$lock->release(); //Finally is not supported in older PHP versions, so it is necessary to release the lock in two places
foreach ($results as $row) {
$row_roles = Utility_Serialization::unserialize($row->serialized_roles, array('allowed_classes' => false), 'is_array');
catch (RuntimeException $e) {
$row_roles = array(self::TRUNCATED_ROLE_KEY => true);
foreach ($row_roles as $row_role => $state) {
if ($state !== true || (!$truncated_role && !is_string($row_role)))
if (array_key_exists($row_role, $roles) || $row_role === self::TRUNCATED_ROLE_KEY) {
foreach ($groups as $group => &$group_count) {
if ($group === 'active_avail_roles' && $row->two_factor_inactive)
$counts[$group][$row_role] += $row->user_count;
$group_count += $row->user_count;
foreach ($roles as $role_key => $role_name) {
if ($counts['avail_roles'][$role_key] === 0 && $counts['active_avail_roles'][$role_key] === 0) {
unset($counts['avail_roles'][$role_key]);
unset($counts['active_avail_roles'][$role_key]);
// Separately add super admins for multisite
foreach(get_super_admins() as $username) {
$user = new \WP_User($username);
if ($this->has_2fa_active($user)) {
$counts['avail_roles']['super-admin'] = $superAdmins;
$counts['active_avail_roles']['super-admin'] = $activeSuperAdmins;
$counts['total_users'] = $groups['avail_roles'];
$counts['active_total_users'] = $groups['active_avail_roles'];
* Returns the number of users with 2FA active.
public function active_count() {
$table = Controller_DB::shared()->secrets;
return intval($wpdb->get_var("SELECT COUNT(*) FROM `{$table}`"));
protected function _init_actions() {
add_action('deleted_user', array($this, '_deleted_user'));
add_filter('manage_users_columns', array($this, '_manage_users_columns'));
add_filter('manage_users_custom_column', array($this, '_manage_users_custom_column'), 10, 3);
add_filter('manage_users_sortable_columns', array($this, '_manage_users_sortable_columns'), 10, 1);
add_filter('users_list_table_query_args', array($this, '_users_list_table_query_args'));
add_filter('user_row_actions', array($this, '_user_row_actions'), 10, 2);
add_filter('views_users', array($this, '_views_users'));