db_table = _tbl('users'); $this->column_name_username = 'name'; $this->column_name_display_name = 'display_name'; } /** * @param string|null $user a username or email address */ public function validateUser( #[\SensitiveParameter] ?string $user, #[\SensitiveParameter] ?string $pass) { // The string $user that the user logged on with could be either a username or // an email address, or even possibly just the local part of an email address. // So it's just possible that there is more than one user with this password and // username | email address | local-part. If we get more than one, then we don't // know which user it is, so we return false. $valid_usernames = array(); if (($valid_username = $this->validateUsername($user, $pass)) !== false) { $valid_usernames[] = $valid_username; } $valid_usernames = array_merge($valid_usernames, $this->validateEmail($user, $pass)); $valid_usernames = array_unique($valid_usernames); if (count($valid_usernames) == 1) { $result = $valid_usernames[0]; // 登录成功:复位状态标记(避免残留锁定标记影响后续判断) $this->loginBlocked = false; $this->loginFailed = false; // Update the database with this login, but don't change the timestamp $now = time(); $sql = "UPDATE " . _tbl('users') . " SET last_login=?, timestamp=timestamp WHERE name=?"; $sql_params = array($now, $result); $this->connection()->command($sql, $sql_params); return $result; } else { return false; } } protected function connection() : ?DB { return db(); } /* validateUsername($user, $pass) * * Checks if the specified username/password pair are valid * * $user - The user name * $pass - The password * * Returns: * false - The pair are invalid or do not exist * string - The validated username */ private function validateUsername( #[\SensitiveParameter] ?string $user, #[\SensitiveParameter] ?string $pass) { $sql_params = array(); // We use syntax_casesensitive_equals() rather than just '=' because '=' in MySQL // permits trailing spacings, eg 'john' = 'john '. We could use LIKE, but that then // permits wildcards, so we could use a combination of LIKE and '=' but that's a bit // messy. We could use STRCMP, but that's MySQL only. // Usernames are unique in the users table, so we only look for one. // 等保整改:额外取出 failed_logins / locked_until 用于登录失败锁定 $sql = "SELECT password_hash, name, failed_logins, locked_until FROM " . _tbl('users') . " WHERE " . $this->connection()->syntax_casesensitive_equals('name', mb_strtolower($user), $sql_params) . " LIMIT 1"; $res = $this->connection()->query($sql, $sql_params); $row = $res->next_row_keyed(); // 复位状态标记(一次登录尝试至多设置其一) $this->loginBlocked = false; $this->loginFailed = false; if (!isset($row['password_hash'])) { // No user found with that name. 故意不计数、不区分提示,避免用户名枚举。 return false; } // 等保整改:账号临时锁定检查(连续失败达阈值后 locked_until > now) if ((int)$row['locked_until'] > time()) { $this->loginBlocked = true; return false; } if (!$this->checkPassword($pass, $row['password_hash'], 'name', $row['name'])) { $this->loginFailed = true; $this->registerLoginFailure($row['name'], (int)$row['failed_logins']); return false; } // 密码正确:清零失败计数并解除锁定 $this->clearLoginFailures($row['name']); return $row['name']; } /* authValidateEmail($email, $pass) * * Checks if the specified email/password pair are valid * * $email - The email address * $pass - The password * * Returns: * array - An array of valid usernames, empty if none found */ private function validateEmail( #[\SensitiveParameter] string $email, #[\SensitiveParameter] string $pass) : array { $valid_usernames = array(); // Email addresses are not unique in the users table, so we need to find all of them. $users = self::getUsersByEmail($email); // Check all the users that have this email address and password hash. // 等保整改:同一邮箱多账号时逐个做锁定检查 / 失败计数(封堵 email 登录通道的暴力破解) foreach($users as $user) { if (isset($user['password_hash'])) { if ((int)$user['locked_until'] > time()) { $this->loginBlocked = true; continue; } if ($this->checkPassword($pass, $user['password_hash'], 'email', $email)) { $valid_usernames[] = $user['name']; $this->clearLoginFailures($user['name']); } else { $this->loginFailed = true; $this->registerLoginFailure($user['name'], (int)$user['failed_logins']); } } } return $valid_usernames; } // ===== 等保整改:登录失败锁定 / 口令有效期 / 自助改密 ===== // 上次登录尝试是否因账号锁定被拒 public function getLoginBlocked() : bool { return $this->loginBlocked; } // 上次登录尝试是否因密码错误被拒(区别于“用户不存在”) public function getLoginFailed() : bool { return $this->loginFailed; } // 登录失败计数:达到阈值则设置 locked_until。 // 若账号曾因达到阈值被锁(现已到期解锁),从 1 重新计数,避免“永远差一次就锁”。 private function registerLoginFailure(string $username, int $current_failures) : void { global $login_lock_threshold, $login_lock_duration; $threshold = $login_lock_threshold ?? 5; $duration = $login_lock_duration ?? (15 * 60); if ($current_failures >= $threshold) { $current_failures = 0; } $new_failures = $current_failures + 1; $locked_until = ($new_failures >= $threshold) ? (time() + $duration) : 0; $sql = "UPDATE " . _tbl('users') . " SET failed_logins=?, locked_until=? WHERE name=?"; $this->connection()->command($sql, array($new_failures, $locked_until, $username)); } // 登录成功:清零失败计数与锁定时间 private function clearLoginFailures(string $username) : void { $sql = "UPDATE " . _tbl('users') . " SET failed_logins=0, locked_until=0 WHERE name=?"; $this->connection()->command($sql, array($username)); } // 口令是否需要强制修改(初始口令 / 已超过 90 天有效期) public function needsPasswordChange(string $username) : bool { global $pwd_max_age; $max_age = $pwd_max_age ?? (90 * 24 * 60 * 60); $sql = "SELECT password_changed_at FROM " . _tbl('users') . " WHERE name=? LIMIT 1"; $res = $this->connection()->query($sql, array($username)); $row = $res->next_row_keyed(); if (!isset($row)) { return false; } $changed_at = (int)$row['password_changed_at']; return ($changed_at == 0) || ((time() - $changed_at) > $max_age); } // 自助改密:更新口令并记录修改时间、清零失败计数并解除锁定 public function updatePassword(string $username, string $password) : void { $sql = "UPDATE " . _tbl('users') . " SET password_hash=:password_hash, password_changed_at=:password_changed_at, failed_logins=0, locked_until=0 WHERE name=:name"; $sql_params = array( ':password_hash' => password_hash($password, PASSWORD_DEFAULT), ':password_changed_at' => time(), ':name' => $username ); $this->connection()->command($sql, $sql_params); } protected function getUserFresh(string $username) : ?User { $row = $this->getUserByUsername($username); // The username doesn't exist - return NULL if (!isset($row)) { return null; } // The username does exist - return a User object $user = new User($username); // $user->level and $user->display_name will be set as part of this foreach ($row as $key => $value) { if ($key == 'name') { // This has already been set as the 'username' property; continue; } $user->$key = $value; } return $user; } // Return an array of all users public function getUsers() : array { // Add in an extra column, last_updated, which is the SQL timestamp converted to a UNIX // timestamp. We do the conversion in the SQL query so that it is converted using the // same timezone that it was stored with. $sql = "SELECT *, ". $this->connection()->syntax_timestamp_to_unix("timestamp") . " AS last_updated FROM " . _tbl('users') . " ORDER BY name"; $res = $this->connection()->query($sql); $result = $res->all_rows_keyed(); foreach ($result as &$row) { row_cast_columns($row, 'users'); // Turn the last_updated column into an int (some MySQL drivers will return a string, // and it won't have been caught by row_cast_columns() as it's a derived result). $row['last_updated'] = intval($row['last_updated']); } return $result; } /** */ public function canCreateUsers() : bool { return true; } /** */ public function canValidateByEmail() : bool { return true; } /** */ public function canResetPassword() : bool { return true; } /** */ public function canResetByEmail() : bool { // We allow resetting by email, even if there are multiple users with the // same email address. return $this->canValidateByEmail(); } public function requestPassword(?string $login) : bool { if (!isset($login) || ($login === '')) { return false; } // Get the possible users given this login, which could be a username or email address. // However all the possible users must have the same email address, so check the email // addresses at the same time. $possible_users = array(); $user = $this->getUserByUsername($login); // Users must have an email address otherwise we won't be able to mail a reset link if (isset($user) && isset($user['email']) && ($user['email'] !== '')) { $possible_users[] = $user; } if ($this->canValidateByEmail()) { $users = $this->getUsersByEmail($login); if (!empty($users)) { // Check that the email addresses are the same if (!empty($possible_users) && (mb_strtolower($possible_users[0]['email']) !== mb_strtolower($login))) { return false; } foreach ($users as $user) { $possible_users[] = $user; } } } if (!empty($possible_users)) { // Generate a key $key = generate_token(32); // Update the database if ($this->setResetKey($possible_users, $key)) { // Email the user return $this->notifyUser($possible_users, $key); } } return false; } public function resetPassword( #[\SensitiveParameter] ?string $username, ?string $key, #[\SensitiveParameter] ?string $password) : bool { // Check that we've got a password and we're allowed to reset the password if (!isset($password) || !auth()->isValidReset($username, $key)) { return false; } // Set the new password and clear the reset key // 等保整改:记录口令修改时间(password_changed_at),供 90 天有效期计算 $sql = "UPDATE " . _tbl('users') . " SET password_hash=:password_hash, password_changed_at=:password_changed_at, reset_key_hash=NULL, reset_key_expiry=0 WHERE name=:name"; // PostgreSQL does not support LIMIT with UPDATE $sql_params = array( ':password_hash' => password_hash($password, PASSWORD_DEFAULT), ':password_changed_at' => time(), ':name' => $username ); $this->connection()->command($sql, $sql_params); return true; } public function isValidReset(?string $user, ?string $key) : bool { if (!isset($user) || !isset($key) || ($user === '') || ($key === '')) { return false; } $sql = "SELECT reset_key_hash, reset_key_expiry FROM " . _tbl('users') . " WHERE name=:name LIMIT 1"; $sql_params = array(':name' => $user); $res = $this->connection()->query($sql,$sql_params); // Check we've found a row if ($res->count() == 0) { return false; } $row = $res->next_row_keyed(); // Check that the reset hasn't expired if (time() > $row['reset_key_expiry']) { return false; } // Check we've got the correct key return password_verify($key, $row['reset_key_hash']); } // Returns an unsorted array of registrants display names protected function getRegistrantsDisplayNamesUnsortedWithout(int $id, bool $with_registrant_username) : array { // For the 'db' auth type we can improve performance by doing a single query // on the participants table joined with the users table. (Actually it's two // queries in a UNION: one getting the rows where there isn't an entry in the // users table and another the rows where there is.) $sql = "SELECT P.username as username, P.username as display_name FROM " . _tbl('participants') . " P LEFT JOIN " . _tbl('users') . " U ON P.username=U.name WHERE P.entry_id=:entry_id AND (U.display_name IS NULL OR U.display_name='') UNION SELECT U.name as username, U.display_name as display_name FROM " . _tbl('participants') . " P LEFT JOIN " . _tbl('users') . " U ON P.username=U.name WHERE P.entry_id=:entry_id AND U.display_name IS NOT NULL AND U.display_name!=''"; $result = array(); $res = $this->connection()->query($sql, array(':entry_id' => $id)); while (false !== ($row = $res->next_row_keyed())) { $result[] = ($with_registrant_username) ? format_compound_name($row['username'], $row['display_name']) : $row['display_name']; } return $result; } // Returns an unsorted array of registrants display names, including, if // different, the display name of the person that registered them. protected function getRegistrantsDisplayNamesUnsortedWith(int $id, bool $with_registrant_username) : array { // For the 'db' auth type we can improve performance by doing a single query // on the participants table joined with the users table. (Actually it's four // queries in a UNION: one getting the rows where there isn't an entry in the // users table and another the rows where there is, etc. for both the registrant // and the person that registered them.) $sql = "SELECT P.username as registrant_username, P.username as registrant_display_name, P.create_by as create_by_username, P.create_by as create_by_display_name FROM " . _tbl('participants') . " P LEFT JOIN " . _tbl('users') . " U1 ON P.username=U1.name LEFT JOIN " . _tbl('users') . " U2 ON P.create_by=U2.name WHERE P.entry_id=:entry_id AND (U1.display_name IS NULL OR U1.display_name='') AND (U2.display_name IS NULL OR U2.display_name='') UNION SELECT P.username as registrant_username, P.username as registrant_display_name, P.create_by as create_by_username, U2.display_name as registrant_display_name FROM " . _tbl('participants') . " P LEFT JOIN " . _tbl('users') . " U1 ON P.username=U1.name LEFT JOIN " . _tbl('users') . " U2 ON P.create_by=U2.name WHERE P.entry_id=:entry_id AND (U1.display_name IS NULL OR U1.display_name='') AND U2.display_name IS NOT NULL AND U2.display_name!='' UNION SELECT P.username as registrant_username, U1.display_name as registrant_display_name, P.create_by as create_by_username, P.create_by as registrant_display_name FROM " . _tbl('participants') . " P LEFT JOIN " . _tbl('users') . " U1 ON P.username=U1.name LEFT JOIN " . _tbl('users') . " U2 ON P.create_by=U2.name WHERE P.entry_id=:entry_id AND U1.display_name IS NOT NULL AND U1.display_name!='' AND (U2.display_name IS NULL OR U2.display_name='') UNION SELECT P.username as registrant_username, U1.display_name as registrant_display_name, P.create_by as create_by_username, U2.display_name as registrant_display_name FROM " . _tbl('participants') . " P LEFT JOIN " . _tbl('users') . " U1 ON P.username=U1.name LEFT JOIN " . _tbl('users') . " U2 ON P.create_by=U2.name WHERE P.entry_id=:entry_id AND U1.display_name IS NOT NULL AND U1.display_name!='' AND U2.display_name IS NOT NULL AND U2.display_name!=''"; $result = array(); $res = $this->connection()->query($sql, array(':entry_id' => $id)); while (false !== ($row = $res->next_row_keyed())) { if ($row['registrant_username'] === $row['create_by_username']) { if ($with_registrant_username) { $result[] = format_compound_name($row['registrant_username'], $row['registrant_display_name']); } else { $result[] = $row['registrant_display_name']; } } else { if ($with_registrant_username && ($row['registrant_username'] !== $row['registrant_display_name'])) { $result[] = get_vocab('registrant_username_and_registered_by', $row['registrant_username'], $row['registrant_display_name'], $row['create_by_display_name']); } else { $result[] = get_vocab('registrant_registered_by', $row['registrant_display_name'], $row['create_by_display_name']); } } } return $result; } /** * @return string[] */ protected function getRegistrantsDisplayNamesUnsorted(int $id, bool $with_registered_by, $with_registrant_username) : array { if ($with_registered_by) { return $this->getRegistrantsDisplayNamesUnsortedWith($id, $with_registrant_username); } else { return $this->getRegistrantsDisplayNamesUnsortedWithout($id, $with_registrant_username); } } private function notifyUser(array $users, string $key) : bool { global $auth, $mail_settings; if (empty($users) || !isset($users[0]['email']) || ($users[0]['email'] === '')) { return false; } $expiry_time = $auth['db']['reset_key_expiry']; toTimeString($expiry_time, $expiry_units, true, 'hours'); $addresses = array( 'from' => $mail_settings['from'] ); // Add the To address, using the display name if possible (ie if it exists and there's // only one user). // Also get a name to use in the message body if ((count($users) == 1) && isset($users[0]['display_name']) && ($users[0]['display_name'] !== '')) { $mailer = new PHPMailer(); $mailer->CharSet = Language::MAIL_CHARSET; // Note that addrFormat() returns a MIME-encoded address $addresses['to'] = $mailer->addrFormat(array($users[0]['email'], $users[0]['display_name'])); $name = $users[0]['display_name']; } else { $addresses['to'] = $users[0]['email']; // If there's only one user we can use the username, otherwise we have to use the // email address which is the same for all users. $name = (count($users) == 1) ? $users[0]['name'] : $users[0]['email']; } $subject = get_vocab('password_reset_subject'); $body = '
'; $body .= get_vocab('password_reset_body', intval($expiry_time), $expiry_units, $name); $body .= "
\n"; // Construct and add in the link $usernames = array(); foreach ($users as $user) { $usernames[] = $user['name']; } $usernames = array_unique($usernames); $vars = array( 'action' => 'reset', 'usernames' => $usernames, 'key' => $key ); $query = http_build_query($vars, '', '&'); $href = url_base() . multisite("reset_password.php?$query"); $body .= ""; MailQueue::add( $addresses, $subject, strip_tags($body), $body, null, Language::MAIL_CHARSET ); return true; } private function setResetKey(array $users, string $key) : bool { global $auth; if (empty($users)) { return false; } $ids = array(); foreach($users as $user) { // Use intval to make sure the string is safe for the SQL query $ids[] = intval($user['id']); } $sql = "UPDATE " . _tbl('users') . " SET reset_key_hash=:reset_key_hash, reset_key_expiry=:reset_key_expiry WHERE id IN (" . implode(',', $ids) . ")"; $sql_params = array( ':reset_key_hash' => password_hash($key, PASSWORD_DEFAULT), ':reset_key_expiry' => time() + $auth['db']['reset_key_expiry'] ); $this->connection()->command($sql, $sql_params); return true; } private function getUserByUsername(string $username) : ?array { $sql = "SELECT * FROM " . _tbl('users') . " WHERE name=:name LIMIT 1"; $result = $this->connection()->query($sql, array(':name' => $username)); // The username doesn't exist - return NULL if ($result->count() === 0) { return null; } return $result->next_row_keyed(); } public function getUserByUserId(int $id) : ?User { $sql = "SELECT * FROM " . _tbl('users') . " WHERE id=:id LIMIT 1"; $result = $this->connection()->query($sql, array(':id' => $id)); // The username doesn't exist - return NULL if ($result->count() === 0) { return null; } // The username does exist - return a User object $user = new User(); $row = $result->next_row_keyed(); // $user->level and $user->display_name will be set as part of this foreach ($row as $key => $value) { if ($key == 'name') { $user->username = $value; } $user->$key = $value; } return $user; } public function getUsernameByEmail(string $email) : ?string { $sql = "SELECT name FROM " . _tbl('users') . " WHERE email=?"; $res = $this->connection()->query($sql, array($email)); if ($res->count() == 0) { return null; } if ($res->count() > 1) { // Could maybe do something better here trigger_error("Email address not unique", E_USER_NOTICE); } $row = $res->next_row_keyed(); return $row['name']; } // Returns an array of rows for all users with the email address $email. // Assumes that email addresses are case insensitive. // Allows equivalent Gmail addresses, ie ignores dots in the local part and // treats gmail.com and googlemail.com as equivalent domains. private function getUsersByEmail(string $email) : array { global $auth; $result = array(); // For the moment we will assume that email addresses are case-insensitive. Whilst it is true // on most systems, it isn't always true. The domain is case-insensitive but the local-part can // be case-sensitive. But before we can take account of this, the email addresses in the database // need to be normalised so that all the domain names are stored in lower case. Then it will be // possible to do a case-sensitive comparison. if (mb_strpos($email, '@') === false) { if (!empty($auth['allow_local_part_email'])) { // We're just checking the local-part of the email address $sql_params = array($email); $condition = "LOWER(?)=LOWER(" . $this->connection()->syntax_simple_split('email', '@', 1, $sql_params) .")"; } else { return $result; } } else { $address = parse_email($email); // Invalid email address if ($address === false) { return $result; } // Special case for Gmail addresses: ignore dots in the local part and treat gmail.com and // googlemail.com as equivalent domains. elseif (in_array(mb_strtolower($address['domain']), array('gmail.com', 'googlemail.com'))) { $sql_params = array(str_replace('.', '', $address['local'])); $sql_params[] = $sql_params[0]; $condition = "(LOWER(?) = REPLACE(TRIM(TRAILING '@gmail.com' FROM LOWER(email)), '.', '')) OR " . "(LOWER(?) = REPLACE(TRIM(TRAILING '@googlemail.com' FROM LOWER(email)), '.', ''))"; } // Everything else: check the complete email address else { $sql_params = array($email); $condition = "LOWER(?)=LOWER(email)"; } } $sql = "SELECT * FROM " . _tbl('users') . " WHERE $condition"; $res = $this->connection()->query($sql, $sql_params); while (false !== ($row = $res->next_row_keyed())) { $result[] = $row; } return $result; } private function rehash( #[\SensitiveParameter] string $password, string $column_name, string $column_value) : void { $sql_params = array(password_hash($password, PASSWORD_DEFAULT)); switch ($column_name) { case 'name': $condition = $this->connection()->syntax_casesensitive_equals($column_name, mb_strtolower($column_value), $sql_params); break; case 'email': // For the moment we will assume that email addresses are case insensitive. Whilst it is true // on most systems, it isn't always true. The domain is case insensitive but the local-part can // be case sensitive. But before we can take account of this, the email addresses in the database // need to be normalised so that all the domain names are stored in lower case. Then it will be possible // to do a case sensitive comparison. $sql_params[] = $column_value; $condition = "LOWER($column_name)=LOWER(?)"; break; default: trigger_error("Unsupported column name '$column_name'.", E_USER_NOTICE); return; break; } $sql = "UPDATE " . _tbl('users') . " SET password_hash=? WHERE $condition"; $this->connection()->command($sql, $sql_params); } // Checks $password against $password_hash for the row in the user table // where $column_name=$column_value. Typically $column_name will be either // 'name' or 'email'. // Returns a boolean: true if they match, otherwise false. private function checkPassword( #[\SensitiveParameter] string $password, string $password_hash, string $column_name, string $column_value) : bool { $result = false; $do_rehash = false; /* If the hash starts '$' it's a PHP password hash */ if (substr($password_hash, 0, 1) == '$') { if (password_verify($password, $password_hash)) { $result = true; if (password_needs_rehash($password_hash, PASSWORD_DEFAULT)) { $do_rehash = true; } } } /* Otherwise it's a legacy MD5 hash */ else { if (md5($password) == $password_hash) { $result = true; $do_rehash = true; } } if ($do_rehash) { $this->rehash($password, $column_name, $column_value); } return $result; } }