Docker image / push (push) Canceled after 0s
包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、登录页JS修复等全部改动
982 lines
29 KiB
PHP
982 lines
29 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
namespace MRBS\Auth;
|
||
|
||
use MRBS\DB\DB;
|
||
use MRBS\Language;
|
||
use MRBS\MailQueue;
|
||
use MRBS\User;
|
||
use PHPMailer\PHPMailer\PHPMailer;
|
||
use function MRBS\_tbl;
|
||
use function MRBS\auth;
|
||
use function MRBS\db;
|
||
use function MRBS\format_compound_name;
|
||
use function MRBS\generate_token;
|
||
use function MRBS\get_vocab;
|
||
use function MRBS\multisite;
|
||
use function MRBS\parse_email;
|
||
use function MRBS\row_cast_columns;
|
||
use function MRBS\toTimeString;
|
||
use function MRBS\url_base;
|
||
|
||
class AuthDb extends AuthDbAbstract
|
||
{
|
||
// 等保整改:登录状态标记(供 SessionWithLogin 区分“锁定 / 密码错误 / 用户不存在”)
|
||
// @var bool
|
||
private $loginBlocked = false;
|
||
private $loginFailed = false;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->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 = '<p>';
|
||
$body .= get_vocab('password_reset_body', intval($expiry_time), $expiry_units, $name);
|
||
$body .= "</p>\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 .= "<p><a href=\"$href\">" . get_vocab('reset_password') . "</a>.</p>";
|
||
|
||
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;
|
||
}
|
||
|
||
}
|