包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、登录页JS修复等全部改动
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\Language;
|
||||
use MRBS\User;
|
||||
use function MRBS\format_compound_name;
|
||||
use function MRBS\get_registrants;
|
||||
use function MRBS\get_sortable_name;
|
||||
use function MRBS\get_vocab;
|
||||
use function MRBS\in_arrayi;
|
||||
use function MRBS\session;
|
||||
|
||||
|
||||
abstract class Auth
|
||||
{
|
||||
protected $getDisplayNamesAtOnce = true;
|
||||
|
||||
/**
|
||||
* Checks if the specified username/password pair are valid.
|
||||
*
|
||||
* @return false|string the validated username or false
|
||||
*/
|
||||
abstract public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass);
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
$user = new User($username);
|
||||
$user->display_name = $username;
|
||||
$user->level = $this->getDefaultLevel($username);
|
||||
$user->email = $this->getDefaultEmail($username);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
public function getUser(string $username) : ?User
|
||||
{
|
||||
// Cache results for performance as getting user details in
|
||||
// most authentication types is expensive.
|
||||
static $users = array();
|
||||
|
||||
// Use array_key_exists() rather than isset() in case the value is NULL
|
||||
if (!array_key_exists($username, $users))
|
||||
{
|
||||
// Check to see if this is the current user. If it is, then we
|
||||
// can save ourselves a potentially expensive operation.
|
||||
// But we can only do this if we are not being called by getCurrentUser(), which
|
||||
// some session schemes do, as otherwise we'll end up with an infinite recursion.
|
||||
// TODO: is there a better way of handling this??
|
||||
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
|
||||
$backtrace_functions = array_column($backtrace, 'function');
|
||||
if (!in_array('getCurrentUser', $backtrace_functions))
|
||||
{
|
||||
$mrbs_user = session()->getCurrentUser();
|
||||
}
|
||||
|
||||
if (isset($mrbs_user) && ($mrbs_user->username === $username))
|
||||
{
|
||||
$user = $mrbs_user;
|
||||
}
|
||||
else
|
||||
{
|
||||
$user = $this->getUserFresh($username);
|
||||
// Make sure we've got a sensible display name
|
||||
if (isset($user) &&
|
||||
(!isset($user->display_name) || ($user->display_name === '')))
|
||||
{
|
||||
$user->display_name = $user->username;
|
||||
}
|
||||
}
|
||||
$users[$username] = $user;
|
||||
}
|
||||
|
||||
return $users[$username];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a username given an email address.
|
||||
*
|
||||
* @return string|null The username (or the first found if two or more users share the same email address), or
|
||||
* NULL if no user is found.
|
||||
*/
|
||||
public function getUsernameByEmail(string $email) : ?string
|
||||
{
|
||||
global $mail_settings;
|
||||
|
||||
// Default: return the email address, unless we're constructing email
|
||||
// addresses by adding a domain name onto the username. In which case,
|
||||
// strip off the domain name and then add on any necessary suffix.
|
||||
// This should be the inverse of getDefaultEmail().
|
||||
$result = $email;
|
||||
|
||||
if (isset($mail_settings['domain']) && ($mail_settings['domain'] !== ''))
|
||||
{
|
||||
$at_domain = '@' . self::trimDomain($mail_settings['domain']);
|
||||
if (str_ends_with($email, $at_domain))
|
||||
{
|
||||
// Strip the @domain
|
||||
$result = str_replace($at_domain, '', $result);
|
||||
// And add on the suffix if there is one
|
||||
if (isset($mail_settings['username_suffix']))
|
||||
{
|
||||
$result .= $mail_settings['username_suffix'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public function getDisplayName(?string $username) : ?string
|
||||
{
|
||||
global $get_display_names_all_at_once;
|
||||
|
||||
static $display_names = null; // Cache for performance
|
||||
|
||||
// Easy case 1: $username is null
|
||||
if (!isset($username))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Easy case 2: it's the current user
|
||||
$mrbs_user = session()->getCurrentUser();
|
||||
if (isset($mrbs_user) && ($mrbs_user->username === $username))
|
||||
{
|
||||
return $mrbs_user->display_name;
|
||||
}
|
||||
|
||||
// If we can (and want to) then get all the usernames at the same time. It's
|
||||
// much faster than getting them one at a time when they are stored externally.
|
||||
// Check to see if $display_names is set before getting the usernames, so that
|
||||
// if getUsernames returns false we don't keep on trying it for every username
|
||||
// (the else block will set $display_names).
|
||||
if ($get_display_names_all_at_once &&
|
||||
method_exists($this, 'getUsernames') &&
|
||||
!isset($display_names) &&
|
||||
(false !== ($usernames = $this->getUsernames())))
|
||||
{
|
||||
$display_names = array_column($usernames, 'display_name', 'username');
|
||||
}
|
||||
// Otherwise just get them one at a time
|
||||
else
|
||||
{
|
||||
if (!isset($display_names[$username]))
|
||||
{
|
||||
$user = $this->getUser($username);
|
||||
$display_names[$username] = (isset($user)) ? $user->display_name : $username;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($display_names[$username]) && ($display_names[$username] !== ''))
|
||||
{
|
||||
return $display_names[$username];
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether the authentication type allows the creation of new users.
|
||||
*
|
||||
* This will normally return false if users are managed elsewhere (e.g. on
|
||||
* an external database, or on an LDAP server).
|
||||
*/
|
||||
public function canCreateUsers() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether validation of a user by email address is possible and allowed.
|
||||
*/
|
||||
public function canValidateByEmail() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether validation of a user by username is possible and allowed.
|
||||
*/
|
||||
public function canValidateByUsername() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether the method has a password reset facility
|
||||
*/
|
||||
public function canResetPassword() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether the password can be reset by supplying an email address
|
||||
*/
|
||||
public function canResetByEmail() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates that the password conforms to the password policy.
|
||||
*
|
||||
* Ideally this function should also be matched by client-side
|
||||
* validation, but unfortunately JavaScript's native support for Unicode
|
||||
* pattern matching is very limited. Would need to be implemented using
|
||||
* an add-in library.
|
||||
*/
|
||||
public function validatePassword(
|
||||
#[\SensitiveParameter]
|
||||
string $password) : bool
|
||||
{
|
||||
global $pwd_policy;
|
||||
|
||||
if (isset($pwd_policy))
|
||||
{
|
||||
// Set up regular expressions. Use p{Ll} instead of [a-z] etc.
|
||||
// to make sure accented characters are included
|
||||
$pattern = array('alpha' => '/\p{L}/',
|
||||
'lower' => '/\p{Ll}/',
|
||||
'upper' => '/\p{Lu}/',
|
||||
'numeric' => '/\p{N}/',
|
||||
'special' => '/[^\p{L}|\p{N}]/');
|
||||
// Check for conformance to each rule
|
||||
foreach($pwd_policy as $rule => $value)
|
||||
{
|
||||
switch($rule)
|
||||
{
|
||||
case 'length':
|
||||
if (mb_strlen($password) < $value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// turn on Unicode matching
|
||||
$pattern[$rule] .= 'u';
|
||||
|
||||
$n = preg_match_all($pattern[$rule], $password, $matches);
|
||||
if (($n === false) || ($n < $value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything is OK
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of registrants' display names
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getRegistrantsDisplayNames (array $entry, bool $with_registered_by=false, bool $with_registrant_username=false) : array
|
||||
{
|
||||
$display_names = array();
|
||||
|
||||
// Only bother getting the names if we don't already know how many there are,
|
||||
// or if we know there are definitely some
|
||||
if (!isset($entry['n_registered']) || ($entry['n_registered'] > 0))
|
||||
{
|
||||
$display_names = $this->getRegistrantsDisplayNamesUnsorted($entry['id'], $with_registered_by, $with_registrant_username);
|
||||
usort($display_names, 'MRBS\compare_display_names');
|
||||
}
|
||||
|
||||
return $display_names;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getRegistrantsDisplayNamesUnsorted(int $id, bool $with_registered_by, bool $with_registrant_username) : array
|
||||
{
|
||||
$display_names = array();
|
||||
$registrants = get_registrants($id, false);
|
||||
|
||||
foreach ($registrants as $registrant)
|
||||
{
|
||||
$display_name = $this->getDisplayName($registrant['username']);
|
||||
// Add in the name of the person who registered this user, if required and if different.
|
||||
if ($with_registered_by &&
|
||||
isset($registrant['create_by']) &&
|
||||
($registrant['create_by'] !== $registrant['username']))
|
||||
{
|
||||
if ($with_registrant_username)
|
||||
{
|
||||
$display_names[] = get_vocab("registrant_username_and_registered_by",
|
||||
$registrant['username'],
|
||||
$display_name,
|
||||
$this->getDisplayName($registrant['create_by']));
|
||||
}
|
||||
else
|
||||
{
|
||||
$display_names[] = get_vocab("registrant_registered_by",
|
||||
$display_name,
|
||||
$this->getDisplayName($registrant['create_by']));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$display_names[] = ($with_registrant_username) ? format_compound_name($registrant['username'], $display_name) : $display_name;
|
||||
}
|
||||
}
|
||||
|
||||
return $display_names;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the level from the $auth['admin'] array in the config file
|
||||
*/
|
||||
protected function getDefaultLevel(?string $username) : int
|
||||
{
|
||||
global $auth, $max_level;
|
||||
|
||||
// User not logged in, user level '0'
|
||||
if(!isset($username))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check whether the user is an admin; if not they are level 1.
|
||||
return (isset($auth['admin']) && in_arrayi($username, $auth['admin'])) ? $max_level : 1;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the default email address using config file settings
|
||||
*/
|
||||
protected function getDefaultEmail(?string $username) : string
|
||||
{
|
||||
global $mail_settings;
|
||||
|
||||
if (!isset($username) || $username === '')
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
$email = $username;
|
||||
|
||||
// Remove the suffix, if there is one
|
||||
if (isset($mail_settings['username_suffix']) && ($mail_settings['username_suffix'] !== ''))
|
||||
{
|
||||
$suffix = $mail_settings['username_suffix'];
|
||||
if (substr($email, -strlen($suffix)) === $suffix)
|
||||
{
|
||||
$email = substr($email, 0, -strlen($suffix));
|
||||
}
|
||||
}
|
||||
|
||||
// Add on the domain, if there is one
|
||||
if (isset($mail_settings['domain']) && ($mail_settings['domain'] !== ''))
|
||||
{
|
||||
$email .= '@' . self::trimDomain($mail_settings['domain']);
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Trim any leading '@' character.
|
||||
*
|
||||
* Older versions of MRBS required the '@' character to be included in $mail_settings['domain'],
|
||||
* and we still allow this for backwards compatibility.
|
||||
*/
|
||||
private static function trimDomain(string $domain) : string
|
||||
{
|
||||
return ltrim($domain, '@');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Callback function for comparing two users.
|
||||
*
|
||||
* Compares first by 'display_name' and then by 'username'.
|
||||
*
|
||||
* @param array{username: string, display_name: string} $user1
|
||||
* @param array{username: string, display_name: string} $user2
|
||||
*/
|
||||
private static function compareUsers(array $user1, array $user2) : int
|
||||
{
|
||||
$display_name1 = get_sortable_name($user1['display_name']);
|
||||
$display_name2 = get_sortable_name($user2['display_name']);
|
||||
// Provide fallbacks just in case the display names are NULL or empty
|
||||
$display_name1 = (isset($display_name1) && ($display_name1 !== '')) ? $display_name1 : $user1['username'];
|
||||
$display_name2 = (isset($display_name2) && ($display_name2 !== '')) ? $display_name2 : $user2['username'];
|
||||
|
||||
$collator = new \Collator(Language::getInstance()->getWebLocale());
|
||||
$collator->setStrength(\Collator::SECONDARY); // Case-insensitive, but accent-sensitive
|
||||
$display_name_comparison = $collator->compare($display_name1, $display_name2);
|
||||
|
||||
if ($display_name_comparison === 0)
|
||||
{
|
||||
return $collator->compare($user1['username'], $user2['username']);
|
||||
}
|
||||
|
||||
return $display_name_comparison;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sorts an array of users indexed by 'username' and 'display_name', eg the
|
||||
* output of getUsernames().
|
||||
*
|
||||
* Sorts by display_name then username.
|
||||
*
|
||||
* @param array{array{username: string, display_name: string}} $users
|
||||
* @return void
|
||||
*/
|
||||
protected static function sortUsers(array &$users) : void
|
||||
{
|
||||
usort($users, [__CLASS__, 'compareUsers']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check we've got the right session scheme for the type.
|
||||
*
|
||||
* To be called for those authentication types which require the same session scheme.
|
||||
*
|
||||
* @return void|never
|
||||
*/
|
||||
protected function checkSessionMatchesType()
|
||||
{
|
||||
global $auth;
|
||||
|
||||
if ($auth['session'] !== $auth['type'])
|
||||
{
|
||||
$class = get_called_class();
|
||||
$message = "MRBS configuration error: $class needs \$auth['session'] set to '" . $auth['type'] . "'";
|
||||
die($message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Writes the debug message to the error log together with the calling method and line number.
|
||||
* It assumes that it has been called by a debug method.
|
||||
*/
|
||||
protected static function logDebugMessage(string $message) : void
|
||||
{
|
||||
// Need to go three levels back to get the real calling method.
|
||||
list( , $called, $caller) = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
|
||||
error_log(
|
||||
"[MRBS DEBUG] " .
|
||||
$caller['class'] . $caller['type'] . $caller['function'] . '(' . $called['line'] . ')' .
|
||||
": $message"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
/**
|
||||
* Authentication scheme that uses an Apache "auth basic" password file for user authentication.
|
||||
*
|
||||
* To use this authentication scheme, set the following things in config.inc.php:
|
||||
*
|
||||
* $auth["type"] = "auth_basic";
|
||||
* $auth["auth_basic"]["passwd_file] = "/etc/httpd/htpasswd"; // Example
|
||||
* $auth["auth_basic"]["mode"] = "des"; // The mode of encryption used in
|
||||
* // the file. Must be one of:
|
||||
* // 'des', 'sha' or 'md5'.
|
||||
*
|
||||
* Then, you may configure admin users:
|
||||
*
|
||||
* $auth["admin"][] = "username1";
|
||||
* $auth["admin"][] = "username2";
|
||||
*/
|
||||
class AuthAuthBasic extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// Check if we do not have a username/password
|
||||
if(!isset($user) || !isset($pass))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($auth["auth_basic"]["passwd_file"]))
|
||||
{
|
||||
error_log("auth_basic: passwd file not specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($auth["auth_basic"]["mode"]))
|
||||
{
|
||||
error_log("auth_basic: mode not specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
require_once "File/Passwd/Authbasic.php";
|
||||
|
||||
$f = &File_Passwd::factory('Authbasic');
|
||||
$f->setFile($auth["auth_basic"]["passwd_file"]);
|
||||
$f->setMode($auth["auth_basic"]["mode"]);
|
||||
$f->load();
|
||||
|
||||
if ($f->verifyPasswd($user, $pass) === true)
|
||||
{
|
||||
return $user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\Intl\Locale;
|
||||
use MRBS\Language;
|
||||
use MRBS\User;
|
||||
use phpCAS;
|
||||
use function MRBS\is_https;
|
||||
|
||||
class AuthCas extends Auth
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->checkSessionMatchesType();
|
||||
$this->init();
|
||||
}
|
||||
|
||||
|
||||
// Initialise CAS
|
||||
public function init() : void
|
||||
{
|
||||
global $auth, $server;
|
||||
|
||||
static $init_complete = false;
|
||||
|
||||
if ($init_complete)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// We still use a couple of deprecated features - the phpCAS autoloader instead of composer and
|
||||
// phpCAS::setDebug() instead of phpCAS::setLogger() - so temporarily disable deprecation errors
|
||||
// and restore them later.
|
||||
// TODO: Fix this
|
||||
$old_level = error_reporting();
|
||||
error_reporting($old_level & ~E_USER_DEPRECATED);
|
||||
|
||||
if ($auth['cas']['debug'])
|
||||
{
|
||||
phpCAS::setDebug();
|
||||
phpCAS::setVerbose(true);
|
||||
}
|
||||
|
||||
// Form a client service name if we haven't been given one
|
||||
if (isset($auth['cas']['client_service_name']))
|
||||
{
|
||||
$client_service_name = $auth['cas']['client_service_name'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$client_service_name = ((is_https()) ? 'https' : 'http') . '://' . $server['HTTP_HOST'];
|
||||
$client_service_name .= (isset($server['SERVER_PORT'])) ? ':' . $server['SERVER_PORT'] : '';
|
||||
}
|
||||
|
||||
phpCAS::client(CAS_VERSION_2_0,
|
||||
$auth['cas']['host'],
|
||||
(int)$auth['cas']['port'],
|
||||
$auth['cas']['context'],
|
||||
$client_service_name
|
||||
);
|
||||
|
||||
// Restore the original level of error reporting now that we've made the first
|
||||
// call to phpCAS.
|
||||
error_reporting($old_level);
|
||||
|
||||
if ($auth['cas']['no_server_validation'])
|
||||
{
|
||||
phpCAS::setNoCasServerValidation();
|
||||
}
|
||||
elseif (!empty($auth['cas']['ca_cert_path']))
|
||||
{
|
||||
phpCAS::setCasServerCACert($auth['cas']['ca_cert_path']);
|
||||
}
|
||||
|
||||
// Handle incoming logout requests
|
||||
if (empty($auth['cas']['real_hosts']))
|
||||
{
|
||||
phpCAS::handleLogoutRequests();
|
||||
}
|
||||
else
|
||||
{
|
||||
phpCAS::handleLogoutRequests(true, $auth['cas']['real_hosts']);
|
||||
}
|
||||
|
||||
// Set the language
|
||||
// (The language constants will only be defined after the first call to a phpCAS method)
|
||||
$cas_lang_map = array(
|
||||
'ca' => PHPCAS_LANG_CATALAN,
|
||||
'de' => PHPCAS_LANG_GERMAN,
|
||||
'el' => PHPCAS_LANG_GREEK,
|
||||
'en' => PHPCAS_LANG_ENGLISH,
|
||||
'es' => PHPCAS_LANG_SPANISH,
|
||||
'fr' => PHPCAS_LANG_FRENCH,
|
||||
'gl' => PHPCAS_LANG_GALEGO,
|
||||
'ja' => PHPCAS_LANG_JAPANESE,
|
||||
'pt' => PHPCAS_LANG_PORTUGUESE,
|
||||
'zh' => PHPCAS_LANG_CHINESE_SIMPLIFIED
|
||||
);
|
||||
|
||||
$locale = Locale::parseLocale(Language::getInstance()->getWebLang());
|
||||
|
||||
if (isset($cas_lang_map[$locale['language']]))
|
||||
{
|
||||
phpCAS::setLang($cas_lang_map[$locale['language']]);
|
||||
}
|
||||
|
||||
// Use our own Guzzle request implementation in case curl is not available.
|
||||
$client = phpCAS::getCasClient();
|
||||
$client->setRequestImplementation(__NAMESPACE__ . '\AuthCasGuzzleRequest');
|
||||
|
||||
$init_complete = true;
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
return (phpCAS::isAuthenticated()) ? $user : false;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
$user = new User($username);
|
||||
$user->level = $this->getLevel($username);
|
||||
$user->email = $this->getDefaultEmail($username);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
protected function getLevel(string $username) : int
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// User not logged in, user level '0'
|
||||
if (!isset($username))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If the attribute filters are set, check to see whether the user has
|
||||
// the required attributes
|
||||
if (isset($auth['cas']['filter_attr_name']) &&
|
||||
isset($auth['cas']['filter_attr_values']))
|
||||
{
|
||||
// getAttribute can return either a scalar or an array
|
||||
$actual_values = phpCAS::getAttribute($auth['cas']['filter_attr_name']);
|
||||
if (!is_array($actual_values))
|
||||
{
|
||||
$actual_values = array($actual_values);
|
||||
}
|
||||
// $auth['cas']['filter_attr_values'] can be either a scalar or an array
|
||||
$required_values = $auth['cas']['filter_attr_values'];
|
||||
if (!is_array($required_values))
|
||||
{
|
||||
$required_values = array($required_values);
|
||||
}
|
||||
// If the user doesn't have at least one of the required attributes they are level 0
|
||||
if (count(array_intersect($actual_values, $required_values)) === 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the config file to see whether the user is an admin
|
||||
return $this->getDefaultLevel($username);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use CAS_OutOfSequenceException;
|
||||
use CAS_Request_AbstractRequest;
|
||||
use CAS_Request_RequestInterface;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
/**
|
||||
* A CAS request class that uses Guzzle to make the request, rather than the default curl. Guzzle will
|
||||
* use curl if possible but falls back to the native PHP functions if curl is not available.
|
||||
*/
|
||||
class AuthCasGuzzleRequest extends CAS_Request_AbstractRequest implements CAS_Request_RequestInterface
|
||||
{
|
||||
private $client;
|
||||
private $options = [
|
||||
'headers' => []
|
||||
];
|
||||
private $response_status_code;
|
||||
private $sent = false; // $_sent is private in CAS_Request_AbstractRequest
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->client = new Client();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::addCookie()
|
||||
*/
|
||||
public function addCookie($name, $value) : void
|
||||
{
|
||||
// TODO: Implement addCookie() method.
|
||||
throw new \Exception('Not yet implemented');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::addCookies()
|
||||
*/
|
||||
public function addCookies(array $cookies) : void
|
||||
{
|
||||
// TODO: Implement addCookies() method.
|
||||
throw new \Exception('Not yet implemented');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::addHeader()
|
||||
*/
|
||||
public function addHeader($header) : void
|
||||
{
|
||||
if ($this->sent)
|
||||
{
|
||||
throw new CAS_OutOfSequenceException('Request has already been sent cannot '.__METHOD__);
|
||||
}
|
||||
|
||||
if (preg_match('/^([^:]+):\s*(.+)$/', $header, $matches))
|
||||
{
|
||||
$this->options['headers'][$matches[1]] = $matches[2];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::addHeaders()
|
||||
*/
|
||||
public function addHeaders(array $headers) : void
|
||||
{
|
||||
if ($this->sent)
|
||||
{
|
||||
throw new CAS_OutOfSequenceException('Request has already been sent cannot '.__METHOD__);
|
||||
}
|
||||
|
||||
foreach ($headers as $header)
|
||||
{
|
||||
$this->addHeader($header);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::setPostBody()
|
||||
*/
|
||||
public function setPostBody($body) : void
|
||||
{
|
||||
parent::setPostBody($body);
|
||||
parse_str($body, $this->options['form_params']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::setSslCaCert()
|
||||
*/
|
||||
public function setSslCaCert($caCertPath, $validate_cn = true) : void
|
||||
{
|
||||
parent::setSslCaCert($caCertPath, $validate_cn);
|
||||
$this->options['verify'] = ($validate_cn) ? $caCertPath : false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_RequestInterface::getResponseStatusCode()
|
||||
*/
|
||||
public function getResponseStatusCode() : int
|
||||
{
|
||||
if (!$this->sent)
|
||||
{
|
||||
throw new CAS_OutOfSequenceException('Request has not been sent yet. Cannot '.__METHOD__);
|
||||
}
|
||||
|
||||
return $this->response_status_code;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see CAS_Request_AbstractRequest::sendRequest()
|
||||
*/
|
||||
protected function sendRequest() : bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$method = ($this->isPost) ? 'POST' : 'GET';
|
||||
$this->sent = true;
|
||||
$response = $this->client->request($method, $this->url, $this->options);
|
||||
$this->response_status_code = $response->getStatusCode();
|
||||
$this->storeResponseBody($response->getBody()->getContents());
|
||||
foreach ($response->getHeaders() as $name => $values)
|
||||
{
|
||||
$this->storeResponseHeader(mb_strtolower($name) . ': ' . implode(', ', $values));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
$this->storeErrorMessage( $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
|
||||
class AuthConfig extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// Check if we do not have a username/password
|
||||
if(!isset($user) || !isset($pass) || strlen($pass)==0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((isset($auth["user"][$user]) &&
|
||||
($auth["user"][$user] == $pass)
|
||||
) ||
|
||||
(isset($auth["user"][mb_strtolower($user)]) &&
|
||||
($auth["user"][mb_strtolower($user)] == $pass)
|
||||
))
|
||||
{
|
||||
return $user; // User validated
|
||||
}
|
||||
|
||||
return false; // User unknown or password invalid
|
||||
}
|
||||
|
||||
|
||||
// Return an array of users, indexed by 'username' and 'display_name'
|
||||
public function getUsernames() : array
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$result = array();
|
||||
|
||||
foreach ($auth['user'] as $user => $password)
|
||||
{
|
||||
$result[] = array('username' => $user,
|
||||
'display_name' => $user);
|
||||
}
|
||||
|
||||
// Need to sort the users
|
||||
self::sortUsers($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
/**
|
||||
* Authentication scheme that uses a password hash file as the source for user authentication.
|
||||
*
|
||||
* This supports any password hash format that your installation of PHP supports.
|
||||
*
|
||||
* To use this authentication scheme, set the following things in config.inc.php:
|
||||
*
|
||||
* $auth["type"] = "crypt";
|
||||
* $auth["crypt"]["passwd_file] = "/etc/httpd/mrbs_passwd";
|
||||
*
|
||||
* Then, you may configure admin users:
|
||||
*
|
||||
* $auth["admin"][] = "username1";
|
||||
* $auth["admin"][] = "username2";
|
||||
*/
|
||||
class AuthCrypt extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// Check if we do not have a username/password
|
||||
if(!isset($user) || !isset($pass))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($auth["crypt"]["passwd_file"]))
|
||||
{
|
||||
error_log("auth_crypt: passwd file not specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
$fh = fopen($auth["crypt"]["passwd_file"], "r");
|
||||
if (!$fh)
|
||||
{
|
||||
error_log("auth_crypt: couldn't open passwd file\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
$ret = false; // Default to failure
|
||||
while ($line = fgets($fh))
|
||||
{
|
||||
if (preg_match("/^\Q$user\E:(.*)/", $line, $matches))
|
||||
{
|
||||
if (password_verify($pass, $matches[1]))
|
||||
{
|
||||
$ret = $user; // Success!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fh);
|
||||
return $ret;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\DB\DB;
|
||||
|
||||
abstract class AuthDbAbstract extends Auth
|
||||
{
|
||||
protected $db_table;
|
||||
protected $column_name_username;
|
||||
protected $column_name_display_name;
|
||||
|
||||
/**
|
||||
* Returns a database connection.
|
||||
*
|
||||
* The connection isn't established in the constructor, but here only when it's really necessary, as it can be
|
||||
* expensive establishing a connection when the server is remote. For example, `method_exists(auth(), 'method')`
|
||||
* will end up calling the constructor, but a connection isn't needed just for method_exists().
|
||||
*/
|
||||
abstract protected function connection() : ?DB;
|
||||
|
||||
|
||||
/**
|
||||
* Return an array of users, indexed by 'username' and 'display_name'.
|
||||
*/
|
||||
public function getUsernames() : array
|
||||
{
|
||||
if (isset($this->column_name_display_name) && ($this->column_name_display_name !== ''))
|
||||
{
|
||||
$display_name_column = $this->column_name_display_name;
|
||||
}
|
||||
else
|
||||
{
|
||||
$display_name_column = $this->column_name_username;
|
||||
}
|
||||
|
||||
$quoted_column_name_display_name = $this->connection()->quote($display_name_column);
|
||||
$quoted_column_name_username = $this->connection()->quote($this->column_name_username);
|
||||
|
||||
$sql = "SELECT $quoted_column_name_username AS username,
|
||||
CASE
|
||||
WHEN $quoted_column_name_display_name IS NOT NULL AND $quoted_column_name_display_name!='' THEN $quoted_column_name_display_name
|
||||
ELSE $quoted_column_name_username
|
||||
END AS display_name
|
||||
FROM " . $this->connection()->quote($this->db_table) . "
|
||||
WHERE $quoted_column_name_username IS NOT NULL
|
||||
ORDER BY display_name";
|
||||
|
||||
$res = $this->connection()->query($sql);
|
||||
|
||||
$users = $res->all_rows_keyed();
|
||||
// Although the users may already be sorted, we sort them again because MRBS
|
||||
// offers an option for sorting by first or last name.
|
||||
self::sortUsers($users);
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\DB\DB;
|
||||
use MRBS\DB\DBExternalException;
|
||||
use MRBS\DB\DBFactory;
|
||||
use MRBS\User;
|
||||
use ValueError;
|
||||
|
||||
class AuthDbExt extends AuthDbAbstract
|
||||
{
|
||||
protected $password_format;
|
||||
protected $column_name_password;
|
||||
protected $column_name_email;
|
||||
protected $column_name_level;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// Take our own copies of the settings
|
||||
$vars = array(
|
||||
'db_table',
|
||||
'password_format',
|
||||
'column_name_username',
|
||||
'column_name_display_name',
|
||||
'column_name_password',
|
||||
'column_name_email',
|
||||
'column_name_level'
|
||||
);
|
||||
|
||||
foreach ($vars as $var)
|
||||
{
|
||||
$this->$var = $auth['db_ext'][$var] ?? null;
|
||||
}
|
||||
|
||||
// Backwards compatibility setting
|
||||
if (!isset($this->password_format) && !empty($auth['db_ext']['use_md5_passwords']))
|
||||
{
|
||||
$this->password_format = 'md5';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
// syntax_casesensitive_equals() modifies our SQL params array for us. We need an exact match -
|
||||
// MySQL allows trailing spaces when using an '=' comparison, eg 'john' = 'john '
|
||||
|
||||
$sql_params = array();
|
||||
|
||||
$query = "SELECT " . $this->connection()->quote($this->column_name_password) .
|
||||
"FROM " . $this->connection()->quote($this->db_table) .
|
||||
"WHERE " . $this->connection()->syntax_casesensitive_equals($this->column_name_username,
|
||||
$user,
|
||||
$sql_params);
|
||||
|
||||
$stmt = $this->connection()->query($query, $sql_params);
|
||||
|
||||
// Check whether (a) there's just one result and (b) that password matches
|
||||
return (($stmt->count() === 1) && $this->password_check($pass, $stmt->next_row()[0])) ? $user : false;
|
||||
}
|
||||
|
||||
|
||||
// Checks that a password matches a hash
|
||||
protected function password_check(string $password, string $hash) : bool
|
||||
{
|
||||
switch ($this->password_format)
|
||||
{
|
||||
case 'crypt':
|
||||
case 'password_hash':
|
||||
// Don't call password_needs_rehash() as (a) we may not have UPDATE rights on the external
|
||||
// database and (b) whether the password needs to be updated will depend on the PHP version
|
||||
// on the external system, not this one.
|
||||
return (password_verify($password, $hash));
|
||||
break;
|
||||
case 'plaintext':
|
||||
return hash_equals($hash, $password);
|
||||
break;
|
||||
default:
|
||||
// Check we've got a valid hashing algorithm. From PHP 8.0.0 hash() will do this.
|
||||
if ((version_compare(PHP_VERSION, '8.0.0') < 0) &&
|
||||
!in_array($this->password_format, hash_algos()))
|
||||
{
|
||||
throw new ValueError("'$this->password_format' is not a valid hashing algorithm");
|
||||
}
|
||||
return hash_equals($hash, hash($this->password_format, $password));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
$sql_params = array();
|
||||
|
||||
// Only retrieve the columns we need (a) to minimise the query and (b) to avoid
|
||||
// sending unnecessary information unencrypted over the internet (Remote SQL is
|
||||
// usually unencrypted).
|
||||
$columns = array();
|
||||
|
||||
$properties = array(
|
||||
'column_name_display_name',
|
||||
'column_name_email',
|
||||
'column_name_level'
|
||||
);
|
||||
|
||||
foreach ($properties as $property)
|
||||
{
|
||||
if (isset($this->$property))
|
||||
{
|
||||
$columns[] = $this->$property;
|
||||
}
|
||||
}
|
||||
|
||||
$sql = "SELECT " . implode(', ', array_map(array($this->connection(), 'quote'), $columns)) . "
|
||||
FROM " . $this->connection()->quote($this->db_table) . "
|
||||
WHERE " . $this->connection()->syntax_casesensitive_equals($this->column_name_username,
|
||||
$username,
|
||||
$sql_params) . "
|
||||
LIMIT 1";
|
||||
|
||||
$stmt = $this->connection()->query($sql, $sql_params);
|
||||
|
||||
// The username doesn't exist - return NULL
|
||||
if ($stmt->count() === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The username does exist - return a User object
|
||||
$data = $stmt->next_row_keyed();
|
||||
|
||||
$user = new User($username);
|
||||
|
||||
// Set the email address
|
||||
if (isset($this->column_name_email) && isset($data[$this->column_name_email]))
|
||||
{
|
||||
$user->email = $data[$this->column_name_email];
|
||||
}
|
||||
|
||||
// Set the display name
|
||||
if (isset($this->column_name_display_name) && isset($data[$this->column_name_display_name]))
|
||||
{
|
||||
$user->display_name = $data[$this->column_name_display_name];
|
||||
}
|
||||
|
||||
// Set the level
|
||||
// First get the default level. Any admins defined in the config
|
||||
// file override settings in the external database.
|
||||
$user->level = $this->getDefaultLevel($username);
|
||||
|
||||
// Then if they are not an admin get their level from the external db
|
||||
if ($user->level < 2)
|
||||
{
|
||||
// If there's can entry in the db, then use that
|
||||
if (isset($this->column_name_level) &&
|
||||
($this->column_name_level !== '') &&
|
||||
isset($data[$this->column_name_level]))
|
||||
{
|
||||
$user->level = $data[$this->column_name_level];
|
||||
}
|
||||
}
|
||||
|
||||
// Then set the remaining properties. (We don't set all the properties from
|
||||
// $data initially because we want to preserve the default values if we don't
|
||||
// have data for the four important properties.)
|
||||
// (Note that normally there won't be any extra properties because we have
|
||||
// specified above the columns that we want, but this code is here so that extra
|
||||
// columns can be added if required.)
|
||||
foreach ($data as $key => $value)
|
||||
{
|
||||
if (!property_exists($user, $key))
|
||||
{
|
||||
$user->$key = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
protected function connection(): ?DB
|
||||
{
|
||||
global $auth;
|
||||
|
||||
static $connection = null;
|
||||
|
||||
if (!isset($connection))
|
||||
{
|
||||
if (empty($auth['db_ext']['db_system']))
|
||||
{
|
||||
$auth['db_ext']['db_system'] = 'mysql';
|
||||
}
|
||||
|
||||
// Establish a connection
|
||||
$port = isset($auth['db_ext']['db_port']) ? (int) $auth['db_ext']['db_port'] : null;
|
||||
|
||||
try
|
||||
{
|
||||
$connection = DBFactory::create(
|
||||
$auth['db_ext']['db_system'],
|
||||
$auth['db_ext']['db_host'],
|
||||
$auth['db_ext']['db_username'],
|
||||
$auth['db_ext']['db_password'],
|
||||
$auth['db_ext']['db_name'],
|
||||
false,
|
||||
$port
|
||||
);
|
||||
}
|
||||
catch (\PDOException $e)
|
||||
{
|
||||
// Differentiate between an error in the MRBS database and the external database
|
||||
throw new DBExternalException($e->getMessage(), $e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
/**
|
||||
* Authentication scheme that uses an external script as the source for user authentication.
|
||||
*
|
||||
* To use this authentication scheme, set the following things in config.inc.php:
|
||||
*
|
||||
* $auth["realm"] = "MRBS"; // Or any other string
|
||||
* $auth["type"] = "ext";
|
||||
* $auth["prog"] = "authenticationprogram"; // The full path to the external script
|
||||
* $auth["params"] = "<...>"; // Parameters to pass to the script; #USERNAME# and #PASSWORD#
|
||||
* // will be expanded to the values typed by the user, e.g.
|
||||
* // "/etc/htpasswd #USERNAME# #PASSWORD#"
|
||||
*
|
||||
* Then, you may configure admin users:
|
||||
*
|
||||
* $auth["admin"][] = "username1";
|
||||
* $auth["admin"][] = "username2";
|
||||
*
|
||||
*/
|
||||
class AuthExt extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// Check if we do not have a username/password
|
||||
if(!isset($user) || !isset($pass))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate the command line
|
||||
$cmd = $auth["prog"] . ' ' . $auth["params"];
|
||||
$cmd = str_replace('#USERNAME#', escapeshellarg($user), $cmd);
|
||||
$cmd = str_replace('#PASSWORD#', escapeshellarg($pass), $cmd);
|
||||
|
||||
// Run the program
|
||||
exec($cmd, $output, $ret);
|
||||
|
||||
// If it succeeded, return success
|
||||
if ($ret == 0)
|
||||
{
|
||||
return $user;
|
||||
}
|
||||
|
||||
// return failure
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
class AuthFactory
|
||||
{
|
||||
|
||||
public static function create(string $type)
|
||||
{
|
||||
// Transform the authentication type from lowercase_separated to LowercaseSeparated
|
||||
$parts = explode('_', $type);
|
||||
$parts = array_map('ucfirst', $parts);
|
||||
$class = __NAMESPACE__ . "\\Auth" . implode('', $parts);
|
||||
return new $class;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\User;
|
||||
|
||||
/**
|
||||
* For use with mod_idcheck (http://idcheck.sourceforge.net/).
|
||||
*
|
||||
* Must have `$auth['session']` set to 'remote_user'.
|
||||
*/
|
||||
class AuthIdcheck extends AuthNone
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
global $auth;
|
||||
|
||||
if ($auth['session'] != 'remote_user')
|
||||
{
|
||||
$message = 'MRBS configuration error. If $auth["type"] is set to "idcheck"' .
|
||||
' then $auth["session"] must be set to "remote_user"';
|
||||
die($message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
// Method provided for completeness as it's an abstract method.
|
||||
// However it's not used by the 'remote_user' session scheme.
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
global $server;
|
||||
|
||||
$user = new User($username);
|
||||
$user->level = $this->getDefaultLevel($username);
|
||||
|
||||
// We only know the details of the currently logged in user
|
||||
if (isset($username) && isset($server['REMOTE_USER']) && ($username == $server['REMOTE_USER']))
|
||||
{
|
||||
$user->display_name = $server['IDCHECK_NAME'];
|
||||
$user->email = $server['IDCHECK_MAIL'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$user->display_name = $username;
|
||||
$user->email = '';
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
/**
|
||||
* Authentication scheme that uses IMAP as the source for user authentication.
|
||||
*
|
||||
* To use this authentication scheme, set the following things in config.inc.php:
|
||||
*
|
||||
* $auth["realm"] = "MRBS"; // Or any other string
|
||||
* $auth["type"] = "imap";
|
||||
*
|
||||
* Then, you may configure admin users:
|
||||
*
|
||||
* $auth["admin"][] = "imapuser1";
|
||||
* $auth["admin"][] = "imapuser2";
|
||||
*/
|
||||
class AuthImap extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $imap_host, $imap_port;
|
||||
|
||||
$all_imap_ports = array();
|
||||
|
||||
// Check if we do not have a username/password
|
||||
if (!isset($user) || !isset($pass) || strlen($pass)==0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that if there is an array of hosts and an array of ports
|
||||
// then the number of each is the same
|
||||
if (is_array( $imap_host ) &&
|
||||
is_array( $imap_port ) &&
|
||||
(count($imap_port) != count($imap_host)) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transfer the list of imap hosts to a new value to ensure that
|
||||
// an array is always used.
|
||||
// If a single value is passed then turn it into an array
|
||||
if (is_array( $imap_host ) )
|
||||
{
|
||||
$all_imap_hosts = $imap_host;
|
||||
}
|
||||
else
|
||||
{
|
||||
$all_imap_hosts = array($imap_host);
|
||||
}
|
||||
|
||||
// create an array of the port numbers to match the number of
|
||||
// hosts if a single port number has been passed.
|
||||
if (is_array( $imap_port ) )
|
||||
{
|
||||
$all_imap_ports = $imap_port;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach($all_imap_hosts as $value)
|
||||
{
|
||||
$all_imap_ports[] = $imap_port;
|
||||
}
|
||||
}
|
||||
|
||||
// iterate over all hosts and return if you get a successful login
|
||||
foreach( $all_imap_hosts as $idx => $host)
|
||||
{
|
||||
$error_number = "";
|
||||
$error_string = "";
|
||||
|
||||
// Connect to IMAP-server
|
||||
$stream = fsockopen( $host, $all_imap_ports[$idx], $error_number,
|
||||
$error_string, 15 );
|
||||
if ( $stream )
|
||||
{
|
||||
$response = fgets( $stream, 1024 );
|
||||
$logon_str = "a001 LOGIN \"" . self::quote_imap( $user ) . "\" \"" . self::quote_imap( $pass ) . "\"\r\n";
|
||||
fputs( $stream, $logon_str );
|
||||
$response = fgets( $stream, 1024 );
|
||||
if ( substr( $response, 5, 2 ) == 'OK' )
|
||||
{
|
||||
fputs( $stream, "a002 LOGOUT\r\n" );
|
||||
$response = fgets( $stream, 1024 );
|
||||
fclose( $stream );
|
||||
return $user;
|
||||
}
|
||||
fputs( $stream, "a002 LOGOUT\r\n" );
|
||||
fclose( $stream );
|
||||
}
|
||||
}
|
||||
|
||||
// return failure
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByEmail() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByUsername() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/* quote_imap($str)
|
||||
*
|
||||
* quote char's into valid IMAP string
|
||||
*
|
||||
* $str - String to be quoted
|
||||
*
|
||||
* Returns:
|
||||
* quoted string
|
||||
*/
|
||||
private static function quote_imap(string $str) : string
|
||||
{
|
||||
return preg_replace('/(["\\\\])/', '\\$1', $str);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\Exception;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\Exceptions\AuthFailedException;
|
||||
use Webklex\PHPIMAP\Exceptions\ImapServerErrorException;
|
||||
|
||||
/**
|
||||
* Authentication scheme that uses IMAP as the source for user authentication. If the PHP version is
|
||||
* less than 8.0, it requires you to have the PHP 'imap' extension installed and enabled.
|
||||
*
|
||||
* To use this authentication scheme, set the following things in config.inc.php:
|
||||
*
|
||||
* $auth["realm"] = "MRBS"; // Or any other string
|
||||
* $auth["type"] = "imap_php";
|
||||
*
|
||||
* You must also configure at least:
|
||||
*
|
||||
* $auth["imap_php"]["hostname"] = "mailserver.hostname";
|
||||
*
|
||||
* You can also specify any of the following options:
|
||||
*
|
||||
* $auth["imap_php"]["port"] = 993; // Specifies the port number to connect to
|
||||
* $auth["imap_php"]["ssl"] = true; // Use SSL
|
||||
* $auth["imap_php"]["tls"] = true; // Use TLS
|
||||
* $auth["imap_php"]["novalidate-cert"] = true; // Turn off SSL/TLS certificate validation
|
||||
*
|
||||
* Then, you may configure admin users:
|
||||
*
|
||||
* $auth["admin"][] = "imapuser1";
|
||||
* $auth["admin"][] = "imapuser2";
|
||||
*/
|
||||
class AuthImapPhp extends Auth
|
||||
{
|
||||
private $canUseWebklex;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
assert(
|
||||
version_compare(MRBS_MIN_PHP_VERSION, '8.0') < 0,
|
||||
'The code below is no longer required.'
|
||||
);
|
||||
// The imap extension was removed in PHP 8.4 and so we use the Webklex/PHPIMAP library
|
||||
// instead. However this requires PHP 8.0 or greater.
|
||||
$this->canUseWebklex = (version_compare(PHP_VERSION, '8.0') >= 0);
|
||||
|
||||
if (!$this->canUseWebklex && !function_exists('imap_open'))
|
||||
{
|
||||
throw new Exception("The imap extension is not installed on this server.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// If required, check that the username is from the permitted domain
|
||||
if (isset($auth['imap_php']['user_domain']))
|
||||
{
|
||||
if (!filter_var($user, FILTER_VALIDATE_EMAIL))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
list(, $domain) = explode('@', $user);
|
||||
|
||||
if ($domain != $auth['imap_php']['user_domain'])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->canUseWebklex)
|
||||
{
|
||||
return $this->validateUserLegacy($user, $pass);
|
||||
}
|
||||
|
||||
$cm = new ClientManager();
|
||||
|
||||
$config = [
|
||||
'host' => $auth['imap_php']['hostname'],
|
||||
'username' => $user,
|
||||
'password' => $pass,
|
||||
'protocol' => 'imap'
|
||||
];
|
||||
|
||||
// The defaults are chosen to be compatible with the legacy behaviour.
|
||||
$config['port'] = $auth['imap_php']['port'] ?? 143;
|
||||
$config['validate_cert'] = empty($auth['imap_php']['novalidate-cert']);
|
||||
if (!empty($auth['imap_php']['ssl']))
|
||||
{
|
||||
$config['encryption'] = 'ssl';
|
||||
}
|
||||
elseif (!empty($auth['imap_php']['tls']))
|
||||
{
|
||||
$config['encryption'] = 'tls';
|
||||
}
|
||||
else
|
||||
{
|
||||
$config['encryption'] = '';
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $cm->make($config);
|
||||
$client->connect(); //Connect to the IMAP Server
|
||||
return $user;
|
||||
}
|
||||
catch (ImapServerErrorException | AuthFailedException $e) {
|
||||
// Don't do anything with these exceptions: they are normal when
|
||||
// authentication fails.
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
// We weren't expecting any other exceptions so trigger an error.
|
||||
$message = "Caught exception '" . get_class($e) . "'\n";
|
||||
$message .= $e->getMessage() . "\n" . $e->getTraceAsString();
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private function validateUserLegacy(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$location = '{' . $auth['imap_php']['hostname'];
|
||||
|
||||
if (isset($auth['imap_php']['port']))
|
||||
{
|
||||
$location .= ':' . $auth['imap_php']['port'];
|
||||
}
|
||||
|
||||
$location .= '/imap';
|
||||
|
||||
if (!empty($auth['imap_php']['ssl']))
|
||||
{
|
||||
$location .= '/ssl';
|
||||
}
|
||||
|
||||
if (!empty($auth['imap_php']['tls']))
|
||||
{
|
||||
$location .= '/tls';
|
||||
}
|
||||
|
||||
if (!empty($auth['imap_php']['novalidate-cert']))
|
||||
{
|
||||
$location .= '/novalidate-cert';
|
||||
}
|
||||
|
||||
$location .= '}INBOX';
|
||||
|
||||
$mbox = imap_open($location, $user, $pass);
|
||||
|
||||
if ($mbox !== false)
|
||||
{
|
||||
imap_close($mbox);
|
||||
return $user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByEmail() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByUsername() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use MRBS\Joomla\JFactory;
|
||||
use MRBS\User;
|
||||
|
||||
require_once MRBS_ROOT . '/auth/cms/joomla.inc';
|
||||
|
||||
|
||||
class AuthJoomla extends Auth
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->checkSessionMatchesType();
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
if (version_compare(JVERSION, '5.0', '<'))
|
||||
{
|
||||
$mainframe = JFactory::getApplication('site');
|
||||
}
|
||||
else
|
||||
{
|
||||
$mainframe = Factory::getApplication('site');
|
||||
}
|
||||
|
||||
return $mainframe->login(array('username' => $user, 'password' => $pass)) ? $user : false;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(?string $username=null) : ?User
|
||||
{
|
||||
if ($username === '')
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (version_compare(JVERSION, '5.0', '<'))
|
||||
{
|
||||
$joomla_user = JFactory::getUser($username);
|
||||
}
|
||||
else
|
||||
{
|
||||
$joomla_user = Factory::getUser($username);
|
||||
}
|
||||
|
||||
if ($joomla_user === false)
|
||||
{
|
||||
return new User($username);
|
||||
}
|
||||
|
||||
if ($joomla_user->guest)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = new User($joomla_user->username);
|
||||
$user->display_name = $joomla_user->name;
|
||||
$user->email = $joomla_user->email;
|
||||
$user->level = self::getUserLevel($joomla_user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
// TODO: sort out where getCurrentUser belongs. We have it in both
|
||||
// TODO: Auth and Session for Joomla!
|
||||
public function getCurrentUser() : ?User
|
||||
{
|
||||
return $this->getUserFresh();
|
||||
}
|
||||
|
||||
|
||||
// Return an array of MRBS users, indexed by 'username' and 'display_name'
|
||||
public function getUsernames() : array
|
||||
{
|
||||
$result = array();
|
||||
|
||||
// We only want MRBS users, not all the Joomla users
|
||||
$groups = self::getMRBSGroups();
|
||||
|
||||
// Get the user ids associated with those groups
|
||||
$user_ids = array();
|
||||
|
||||
foreach($groups as $group)
|
||||
{
|
||||
// Include child groups by doing it recursively
|
||||
if (version_compare(JVERSION, '5.0', '<'))
|
||||
{
|
||||
$user_ids = array_merge($user_ids, \JAccess::getUsersByGroup($group, $recursive = true));
|
||||
}
|
||||
else
|
||||
{
|
||||
$user_ids = array_merge($user_ids, \Joomla\CMS\Access\Access::getUsersByGroup($group, $recursive = true));
|
||||
}
|
||||
}
|
||||
|
||||
$user_ids = array_unique($user_ids);
|
||||
|
||||
// No doubt it would be faster to do this with a single SQL query, but then we wouldn't
|
||||
// be using the Joomla API abstraction.
|
||||
foreach ($user_ids as $user_id)
|
||||
{
|
||||
if (version_compare(JVERSION, '5.0', '<'))
|
||||
{
|
||||
$user = JFactory::getUser((int)$user_id);
|
||||
}
|
||||
else
|
||||
{
|
||||
$user = Factory::getUser((int)$user_id);
|
||||
}
|
||||
// Check to see that the user has a username. The result of getUser() on a user_id that doesn't exist is,
|
||||
// strangely, a user object with all properties set to null. In theory (?) all the user_ids returned by
|
||||
// getUsersByGroup() should exist, but there has been a case where this is not so. See
|
||||
// https://github.com/meeting-room-booking-system/mrbs-code/issues/3682 .
|
||||
if (isset($user->username))
|
||||
{
|
||||
$result[] = array(
|
||||
'username' => $user->username,
|
||||
'display_name' => $user->name
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
trigger_error("The Joomla user with id $user_id appears in Joomla groups but not in Joomla users.", E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to sort the users
|
||||
self::sortUsers($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// Get an array of Joomla groups that have MRBS user or admin rights
|
||||
private static function getMRBSGroups() : array
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$result = array();
|
||||
|
||||
// Get all the Joomla access levels that have MRBS user or admin rights
|
||||
$mrbs_access_levels = array_merge($auth['joomla']['admin_access_levels'],
|
||||
$auth['joomla']['user_access_levels']);
|
||||
|
||||
$mrbs_access_levels = array_unique($mrbs_access_levels);
|
||||
|
||||
// There doesn't seem to be a Joomla API to do this, so we'll have to do
|
||||
// it with direct access to the database.
|
||||
|
||||
// Get a db connection.
|
||||
if (version_compare(JVERSION, '5.0', '<'))
|
||||
{
|
||||
$db = JFactory::getDbo();
|
||||
}
|
||||
else
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
}
|
||||
|
||||
|
||||
// Create a new query object.
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
// Execute the query
|
||||
$query->select($db->quoteName(array('rules')));
|
||||
$query->from($db->quoteName('#__viewlevels'));
|
||||
$query->where($db->quoteName('id') . ' IN ('. implode(',', $mrbs_access_levels) . ')');
|
||||
$db->setQuery($query);
|
||||
$column = $db->loadColumn();
|
||||
|
||||
// Process the results into an array
|
||||
foreach ($column as $rules)
|
||||
{
|
||||
$result = array_merge($result, (json_decode($rules)));
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
$result = array_unique($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
private static function getUserLevel(object $joomla_user) : int
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$required_class = (version_compare(JVERSION, '5.0', '<')) ? 'MRBS\Joomla\JUser' : 'Joomla\CMS\User\User';
|
||||
$actual_class = get_class($joomla_user);
|
||||
if ($actual_class !== $required_class)
|
||||
{
|
||||
$message = 'Argument #1 ($joomla_user) must be of type ' . "$required_class, $actual_class given";
|
||||
throw new \TypeError($message);
|
||||
}
|
||||
|
||||
// User not logged in, user level '0'
|
||||
if ($joomla_user->guest)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Otherwise get the user's access levels
|
||||
$authorised_levels = $joomla_user->getAuthorisedViewLevels();
|
||||
|
||||
// Check if they have admin access
|
||||
if (isset($auth['joomla']['admin_access_levels']))
|
||||
{
|
||||
$admin_levels = (array)$auth['joomla']['admin_access_levels'];
|
||||
if (count(array_intersect($authorised_levels, $admin_levels)) > 0)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if they have user access
|
||||
if (isset($auth['joomla']['user_access_levels']))
|
||||
{
|
||||
$user_levels = (array)$auth['joomla']['user_access_levels'];
|
||||
if (count(array_intersect($authorised_levels, $user_levels)) > 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Everybody else is access level '0'
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\Errors\Errors;
|
||||
use MRBS\Exception;
|
||||
use MRBS\User;
|
||||
use function MRBS\get_microtime;
|
||||
use function MRBS\in_arrayi;
|
||||
use function MRBS\session;
|
||||
|
||||
|
||||
class AuthLdap extends Auth
|
||||
{
|
||||
// LDAP ERROR CODES
|
||||
const LDAP_SUCCESS = 0x00;
|
||||
const LDAP_OPERATIONS_ERROR = 0x01;
|
||||
const LDAP_PROTOCOL_ERROR = 0x02;
|
||||
const LDAP_TIMELIMIT_EXCEEDED = 0x03;
|
||||
const LDAP_SIZELIMIT_EXCEEDED = 0x04;
|
||||
const LDAP_COMPARE_FALSE = 0x05;
|
||||
const LDAP_COMPARE_TRUE = 0x06;
|
||||
const LDAP_AUTH_METHOD_NOT_SUPPORTED = 0x07;
|
||||
const LDAP_STRONG_AUTH_REQUIRED = 0x08;
|
||||
// Not used in LDAPv3
|
||||
const LDAP_PARTIAL_RESULTS = 0x09;
|
||||
|
||||
// Next 5 new in LDAPv3
|
||||
const LDAP_REFERRAL = 0x0a;
|
||||
const LDAP_ADMINLIMIT_EXCEEDED = 0x0b;
|
||||
const LDAP_UNAVAILABLE_CRITICAL_EXTENSION = 0x0c;
|
||||
const LDAP_CONFIDENTIALITY_REQUIRED = 0x0d;
|
||||
const LDAP_SASL_BIND_INPROGRESS = 0x0e;
|
||||
|
||||
const LDAP_NO_SUCH_ATTRIBUTE = 0x10;
|
||||
const LDAP_UNDEFINED_TYPE = 0x11;
|
||||
const LDAP_INAPPROPRIATE_MATCHING = 0x12;
|
||||
const LDAP_CONSTRAINT_VIOLATION = 0x13;
|
||||
const LDAP_TYPE_OR_VALUE_EXISTS = 0x14;
|
||||
const LDAP_INVALID_SYNTAX = 0x15;
|
||||
|
||||
const LDAP_NO_SUCH_OBJECT = 0x20;
|
||||
const LDAP_ALIAS_PROBLEM = 0x21;
|
||||
const LDAP_INVALID_DN_SYNTAX = 0x22;
|
||||
// Next two not used in LDAPv3 =
|
||||
const LDAP_IS_LEAF = 0x23;
|
||||
const LDAP_ALIAS_DEREF_PROBLEM = 0x24;
|
||||
|
||||
const LDAP_INAPPROPRIATE_AUTH = 0x30;
|
||||
const LDAP_INVALID_CREDENTIALS = 0x31;
|
||||
const LDAP_INSUFFICIENT_ACCESS = 0x32;
|
||||
const LDAP_BUSY = 0x33;
|
||||
const LDAP_UNAVAILABLE = 0x34;
|
||||
const LDAP_UNWILLING_TO_PERFORM = 0x35;
|
||||
const LDAP_LOOP_DETECT = 0x36;
|
||||
|
||||
const LDAP_SORT_CONTROL_MISSING = 0x3C;
|
||||
const LDAP_INDEX_RANGE_ERROR = 0x3D;
|
||||
|
||||
const LDAP_NAMING_VIOLATION = 0x40;
|
||||
const LDAP_OBJECT_CLASS_VIOLATION = 0x41;
|
||||
const LDAP_NOT_ALLOWED_ON_NONLEAF = 0x42;
|
||||
const LDAP_NOT_ALLOWED_ON_RDN = 0x43;
|
||||
const LDAP_ALREADY_EXISTS = 0x44;
|
||||
const LDAP_NO_OBJECT_CLASS_MODS = 0x45;
|
||||
const LDAP_RESULTS_TOO_LARGE = 0x46;
|
||||
// Next two for LDAPv3
|
||||
const LDAP_AFFECTS_MULTIPLE_DSAS = 0x47;
|
||||
const LDAP_OTHER = 0x50;
|
||||
|
||||
// Used by some APIs
|
||||
const LDAP_SERVER_DOWN = 0x51;
|
||||
const LDAP_LOCAL_ERROR = 0x52;
|
||||
const LDAP_ENCODING_ERROR = 0x53;
|
||||
const LDAP_DECODING_ERROR = 0x54;
|
||||
const LDAP_TIMEOUT = 0x55;
|
||||
const LDAP_AUTH_UNKNOWN = 0x56;
|
||||
const LDAP_FILTER_ERROR = 0x57;
|
||||
const LDAP_USER_CANCELLED = 0x58;
|
||||
const LDAP_PARAM_ERROR = 0x59;
|
||||
const LDAP_NO_MEMORY = 0x5a;
|
||||
|
||||
// Preliminary LDAPv3 codes
|
||||
const LDAP_CONNECT_ERROR = 0x5b;
|
||||
const LDAP_NOT_SUPPORTED = 0x5c;
|
||||
const LDAP_CONTROL_NOT_FOUND = 0x5d;
|
||||
const LDAP_NO_RESULTS_RETURNED = 0x5e;
|
||||
const LDAP_MORE_RESULTS_TO_RETURN = 0x5f;
|
||||
const LDAP_CLIENT_LOOP = 0x60;
|
||||
const LDAP_REFERRAL_LIMIT_EXCEEDED = 0x61;
|
||||
|
||||
// Default ports
|
||||
const DEFAULT_PORT_LDAP = 389;
|
||||
const DEFAULT_PORT_LDAPS = 636;
|
||||
|
||||
private static $all_ldap_opts;
|
||||
private static $config_items;
|
||||
private static $profile_clock;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
global $ldap_host;
|
||||
global $ldap_port;
|
||||
global $ldap_v3;
|
||||
global $ldap_tls;
|
||||
global $ldap_base_dn;
|
||||
global $ldap_user_attrib;
|
||||
global $ldap_dn_search_attrib;
|
||||
global $ldap_dn_search_dn;
|
||||
global $ldap_dn_search_password;
|
||||
global $ldap_filter;
|
||||
global $ldap_group_member_attrib;
|
||||
global $ldap_admin_group_dn;
|
||||
global $ldap_email_attrib;
|
||||
global $ldap_name_attrib;
|
||||
global $ldap_disable_referrals;
|
||||
global $ldap_deref;
|
||||
global $ldap_filter_base_dn;
|
||||
global $ldap_filter_user_attr;
|
||||
|
||||
// Check that ldap is installed
|
||||
if (!function_exists('ldap_connect'))
|
||||
{
|
||||
die("<hr><p><b>ERROR: PHP's 'ldap' extension is not installed/enabled. ".
|
||||
"Please check your web server configuration.</b></p><hr>\n");
|
||||
}
|
||||
|
||||
// Transfer the values from the config variables into a local
|
||||
// associative array, turning them all into arrays
|
||||
self::$config_items = array('ldap_host',
|
||||
'ldap_port',
|
||||
'ldap_base_dn',
|
||||
'ldap_user_attrib',
|
||||
'ldap_dn_search_attrib',
|
||||
'ldap_dn_search_dn',
|
||||
'ldap_dn_search_password',
|
||||
'ldap_filter',
|
||||
'ldap_group_member_attrib',
|
||||
'ldap_admin_group_dn',
|
||||
'ldap_v3',
|
||||
'ldap_tls',
|
||||
'ldap_email_attrib',
|
||||
'ldap_name_attrib',
|
||||
'ldap_disable_referrals',
|
||||
'ldap_deref',
|
||||
'ldap_filter_base_dn',
|
||||
'ldap_filter_user_attr',
|
||||
'ldap_client_cert',
|
||||
'ldap_client_key'
|
||||
);
|
||||
|
||||
self::$all_ldap_opts = array();
|
||||
|
||||
// Get the array items (we'll handle the non-array items in a moment) and check
|
||||
// that they all have the same length
|
||||
$count = null;
|
||||
|
||||
foreach (self::$config_items as $item)
|
||||
{
|
||||
if (isset($$item) && is_array($$item))
|
||||
{
|
||||
self::$all_ldap_opts[$item] = $$item;
|
||||
if (isset($count))
|
||||
{
|
||||
if (count($$item) != $count)
|
||||
{
|
||||
Errors::fatalError("MRBS configuration error: Count of LDAP array config variables doesn't match, aborting!");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$count = count($$item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turn any non-array config items into arrays
|
||||
if (!isset($count))
|
||||
{
|
||||
$count = 1;
|
||||
}
|
||||
|
||||
foreach (self::$config_items as $item)
|
||||
{
|
||||
if (isset($$item) && !is_array($$item))
|
||||
{
|
||||
self::$all_ldap_opts[$item] = array_fill(0, $count, $$item);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
// Check if we do not have a username/password
|
||||
// User can always bind to LDAP anonymously with empty password,
|
||||
// therefore we need to block empty password here...
|
||||
if (!isset($user) || !isset($pass) || strlen($pass)==0)
|
||||
{
|
||||
self::debug('empty username or password passed');
|
||||
return false;
|
||||
}
|
||||
|
||||
$object = array();
|
||||
$object['pass'] = $pass;
|
||||
|
||||
return ($this->action('validateUserCallback', $user, $object)) ? $user : false;
|
||||
}
|
||||
|
||||
|
||||
/* validateUserCallback(&$ldap, $base_dn, $dn, $user_search,
|
||||
$user, &$object)
|
||||
*
|
||||
* Checks if the specified username/password pair are valid
|
||||
*
|
||||
* &$ldap - Reference to the LDAP object
|
||||
* $base_dn - The base DN
|
||||
* $dn - The user's DN
|
||||
* $user_search - The LDAP filter to find the user
|
||||
* $user - The user name
|
||||
* &$object - Reference to the generic object
|
||||
*
|
||||
* Returns:
|
||||
* false - Didn't find a user
|
||||
* true - Found a user
|
||||
*/
|
||||
private static function validateUserCallback(&$ldap, $base_dn, $dn, $user_search,
|
||||
$user, &$object)
|
||||
{
|
||||
self::debug("base_dn '$base_dn' dn '$dn' user '$user'");
|
||||
|
||||
$pass = $object['pass'];
|
||||
|
||||
// try an authenticated bind
|
||||
// use this to confirm that the user/password pair
|
||||
if ($dn && self::ldapBind($ldap, $dn, $pass))
|
||||
{
|
||||
// however if there is a filter check that the
|
||||
// user is part of the group defined by the filter
|
||||
if (!isset($object['config']['ldap_filter']) || ($object['config']['ldap_filter'] === ''))
|
||||
{
|
||||
self::debug("successful authenticated bind with no \$ldap_filter");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If we've got a search DN and password, then bind again using those credentials because
|
||||
// it's possible that the user doesn't have read access in the directory, even for their own
|
||||
// entry, in which case we'll get a "No such object" result.
|
||||
if (isset($object['config']['ldap_dn_search_dn']) &&
|
||||
isset($object['config']['ldap_dn_search_password']))
|
||||
{
|
||||
self::debug("rebinding as '" . $object['config']['ldap_dn_search_dn'] . "'");
|
||||
if (!self::ldapBind($ldap, $object['config']['ldap_dn_search_dn'], $object['config']['ldap_dn_search_password']))
|
||||
{
|
||||
self::debug("rebinding failed: " . self::ldapError($ldap));
|
||||
return false;
|
||||
}
|
||||
self::debug('rebinding successful');
|
||||
}
|
||||
|
||||
$filter = $object['config']['ldap_filter'];
|
||||
|
||||
self::debug("successful authenticated bind checking '$filter'");
|
||||
|
||||
// If ldap_filter_base_dn is set, set the filter to search for the user
|
||||
// in the given base_dn (OpenLDAP). If not, read from the user
|
||||
// attribute (AD)
|
||||
if (isset($object['config']['ldap_filter_base_dn']))
|
||||
{
|
||||
$f = "(&(".
|
||||
$object['config']['ldap_filter_user_attr'].
|
||||
"=$user)($filter))";
|
||||
$filter_dn = $object['config']['ldap_filter_base_dn'];
|
||||
$call = 'ldap_search';
|
||||
}
|
||||
else
|
||||
{
|
||||
$f = "($filter)";
|
||||
$filter_dn = $dn;
|
||||
$call = 'ldap_read';
|
||||
}
|
||||
|
||||
self::debug("trying filter: $f: dn: $filter_dn: method: $call");
|
||||
|
||||
$res = $call(
|
||||
$ldap,
|
||||
$filter_dn,
|
||||
$f,
|
||||
array()
|
||||
);
|
||||
if (ldap_count_entries($ldap, $res) > 0)
|
||||
{
|
||||
self::debug('found entry with filter');
|
||||
return true;
|
||||
}
|
||||
self::debug('no entry found with filter');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self::debug("bind to '$dn' failed: ". self::ldapError($ldap));
|
||||
}
|
||||
|
||||
// return failure if no connection is established
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
if (!isset($username) || ($username === ''))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$object = array();
|
||||
|
||||
$res = $this->action('getUserCallback', $username, $object);
|
||||
if (!$res || !isset($object['user']))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use $object['user']['username'] rather than $username because they won't necessarily be
|
||||
// the same. See https://sourceforge.net/p/mrbs/bugs/518/
|
||||
$user = parent::getUserFresh($object['user']['username']);
|
||||
$keys = array('display_name', 'email', 'level');
|
||||
|
||||
foreach ($keys as $key)
|
||||
{
|
||||
if (isset($object['user'][$key]))
|
||||
{
|
||||
$user->$key = $object['user'][$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
/* getUserCallback(&$ldap, $base_dn, $dn, $user_search,
|
||||
$username, &$object)
|
||||
*
|
||||
* &$ldap - Reference to the LDAP object
|
||||
* $base_dn - The base DN
|
||||
* $dn - The user's DN
|
||||
* $user_search - The LDAP filter to find the user
|
||||
* $username - The user name
|
||||
* &$object - Reference to the generic object
|
||||
*
|
||||
* Returns:
|
||||
* false - Didn't find a user
|
||||
* true - Found a user
|
||||
*/
|
||||
private static function getUserCallback(&$ldap, $base_dn, $dn, $user_search,
|
||||
$user, &$object)
|
||||
{
|
||||
global $ldap_get_user_email, $ldap_debug_attributes, $max_level;
|
||||
|
||||
self::debug("base_dn '$base_dn' dn '$dn' user_search '$user_search' user '$user'");
|
||||
|
||||
if (!$ldap || !$base_dn || !$dn || !$user_search)
|
||||
{
|
||||
self::debug("invalid parameters, could not call ldap_read, returning false");
|
||||
return false;
|
||||
}
|
||||
|
||||
$attributes = self::getAttributes($object, $ldap_get_user_email, true);
|
||||
|
||||
self::resetProfileClock();
|
||||
// We suppress the errors because it's possible to get a "No such object" error if
|
||||
// the DN doesn't exist - which it won't if (a) we're searching an array of LDAP hosts
|
||||
// or (b) the DN has been deleted since the booking was made. But check the error
|
||||
// code afterwards and trigger an error if it was any other kind of error.
|
||||
$res = @ldap_read(
|
||||
$ldap,
|
||||
$dn,
|
||||
"(objectclass=*)",
|
||||
array_values($attributes),
|
||||
0,
|
||||
1
|
||||
);
|
||||
$t = self::getProfileClock();
|
||||
|
||||
if ($res === false)
|
||||
{
|
||||
self::debug("ldap_read() failed: " . self::ldapError($ldap));
|
||||
if (self::LDAP_NO_SUCH_OBJECT !== ($errno = ldap_errno($ldap)))
|
||||
{
|
||||
if ($errno === self::LDAP_SUCCESS)
|
||||
{
|
||||
// The errno is reporting success, but that's just the ldap errno. If ldap_read()
|
||||
// doesn't get as far as interrogating the directory, it will return false,
|
||||
// but ldap_errno() will return success. To get more details try temporarily
|
||||
// removing the error suppression operator ('@').
|
||||
$message = "ldap_read() failed, not due to an LDAP error but probably due " .
|
||||
"to an initialization error such as 'Array initialization wrong'";
|
||||
}
|
||||
else
|
||||
{
|
||||
$message = ldap_err2str($errno);
|
||||
}
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$n_entries = ldap_count_entries($ldap, $res);
|
||||
|
||||
if ($n_entries === false)
|
||||
{
|
||||
self::debug("No entries found - ldap_count_entries() error");
|
||||
return false;
|
||||
}
|
||||
|
||||
self::debug("$n_entries entries found");
|
||||
|
||||
if ($n_entries === 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self::debug("ldap_read() succeeded, taking $t seconds");
|
||||
|
||||
if ($ldap_debug_attributes)
|
||||
{
|
||||
// Repeat the read, this time fetching all the attributes and then write
|
||||
// the attributes and their values to the debug log. Useful for discovering
|
||||
// attribute names.
|
||||
$res2 = @ldap_read($ldap, $dn, "(objectclass=*)", [], 0, 1);
|
||||
$entry = ldap_first_entry($ldap, $res2);
|
||||
$attribute = ldap_first_attribute($ldap, $entry);
|
||||
while ($attribute)
|
||||
{
|
||||
$values = ldap_get_values($ldap, $entry, $attribute);
|
||||
unset($values['count']); // We don't need this element
|
||||
self::debug("Attribute: \"$attribute\"; Value(s): \"" . implode('", "', $values) . '"');
|
||||
$attribute = ldap_next_attribute($ldap, $entry);
|
||||
}
|
||||
}
|
||||
|
||||
$entry = ldap_first_entry($ldap, $res);
|
||||
$user = self::getResult($ldap, $entry, $attributes);
|
||||
|
||||
if (!isset($user['username']))
|
||||
{
|
||||
$message = 'No username found. Check the value of $ldap_user_attrib in the MRBS config file.';
|
||||
$message .= " It is currently set to '" . $object['config']['ldap_user_attrib'] . "'.";
|
||||
self::debug($message);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isset($user['display_name']))
|
||||
{
|
||||
$user['display_name'] = $user['username'];
|
||||
}
|
||||
|
||||
if (isset($user['groups']))
|
||||
{
|
||||
if (isset($object['config']['ldap_admin_group_dn']))
|
||||
{
|
||||
$user['level'] = in_arrayi($object['config']['ldap_admin_group_dn'], $user['groups']) ? $max_level : 1;
|
||||
}
|
||||
}
|
||||
|
||||
self::debug("User '" . $user['username'] . "' found");
|
||||
$object['user'] = $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function getUsernames()
|
||||
{
|
||||
$mrbs_user = session()->getCurrentUser();
|
||||
|
||||
if (!isset($mrbs_user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$object = array();
|
||||
$object['users'] = array();
|
||||
$users = array();
|
||||
|
||||
$res = $this->action('getUsernamesCallback', $mrbs_user->username, $object, true);
|
||||
|
||||
if ($res === false)
|
||||
{
|
||||
trigger_error("MRBS: could not get LDAP usernames.", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($object['users']))
|
||||
{
|
||||
$users = $object['users'];
|
||||
}
|
||||
|
||||
self::sortUsers($users);
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
|
||||
private static function getUsernamesCallback(&$ldap, $base_dn, $dn, $user_search,
|
||||
$user, &$object)
|
||||
{
|
||||
self::debug("base_dn '$base_dn'");
|
||||
|
||||
if (!$ldap || !$base_dn || !isset($object['config']['ldap_user_attrib']))
|
||||
{
|
||||
self::debug("invalid parameters, could not call ldap_search, returning false");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($object['config']['ldap_filter']))
|
||||
{
|
||||
$filter = $object['config']['ldap_filter'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$filter = 'objectclass=*';
|
||||
}
|
||||
$filter = "($filter)";
|
||||
|
||||
// Form the attributes
|
||||
$username_attrib = mb_strtolower($object['config']['ldap_user_attrib']);
|
||||
$attributes = array($username_attrib);
|
||||
|
||||
// The display name attribute might not have been set in the config file
|
||||
if (isset($object['config']['ldap_name_attrib']))
|
||||
{
|
||||
$display_name_attrib = mb_strtolower($object['config']['ldap_name_attrib']);
|
||||
$attributes[] = $display_name_attrib;
|
||||
}
|
||||
|
||||
self::debug("searching with base_dn '$base_dn' and filter '$filter'");
|
||||
self::resetProfileClock();
|
||||
$res = ldap_search($ldap, $base_dn, $filter, $attributes);
|
||||
$t = self::getProfileClock();
|
||||
|
||||
if ($res === false)
|
||||
{
|
||||
self::debug("ldap_search failed: " . self::ldapError($ldap));
|
||||
return false;
|
||||
}
|
||||
|
||||
self::debug(ldap_count_entries($ldap, $res) . " entries found in $t seconds");
|
||||
|
||||
$entry = ldap_first_entry($ldap, $res);
|
||||
|
||||
// Loop through the entries to get all the users
|
||||
while ($entry)
|
||||
{
|
||||
// Initialise all keys in the user array to NULL, in case an attribute isn't present
|
||||
$user = array('username' => null,
|
||||
'display_name' => null);
|
||||
|
||||
$attribute = ldap_first_attribute($ldap, $entry);
|
||||
|
||||
// Loop through all the attributes for this user
|
||||
while ($attribute)
|
||||
{
|
||||
$values = ldap_get_values($ldap, $entry, $attribute);
|
||||
$attribute = mb_strtolower($attribute); // ready for the comparisons
|
||||
|
||||
if ($attribute == $username_attrib)
|
||||
{
|
||||
$user['username'] = $values[0];
|
||||
}
|
||||
elseif ($attribute == $display_name_attrib)
|
||||
{
|
||||
$user['display_name'] = $values[0];
|
||||
}
|
||||
|
||||
$attribute = ldap_next_attribute($ldap, $entry);
|
||||
}
|
||||
|
||||
if (isset($user['username']))
|
||||
{
|
||||
if (!isset($user['display_name']))
|
||||
{
|
||||
$user['display_name'] = $user['username'];
|
||||
}
|
||||
$object['users'][] = $user;
|
||||
}
|
||||
|
||||
$entry = ldap_next_entry($ldap, $entry);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Returns an array of attributes for use in an LDAP query
|
||||
private static function getAttributes(array $object, bool $include_email=true, bool $include_groups=true) : array
|
||||
{
|
||||
$result = array();
|
||||
|
||||
// Username
|
||||
$result['username'] = mb_strtolower($object['config']['ldap_user_attrib']);
|
||||
|
||||
// The display name attribute might not have been set in the config file
|
||||
if (isset($object['config']['ldap_name_attrib']))
|
||||
{
|
||||
$result['display_name'] = mb_strtolower($object['config']['ldap_name_attrib']);
|
||||
}
|
||||
|
||||
// The email address
|
||||
if ($include_email && isset($object['config']['ldap_email_attrib']))
|
||||
{
|
||||
$result['email'] = mb_strtolower($object['config']['ldap_email_attrib']);
|
||||
}
|
||||
|
||||
// The group name attribute might not have been set in the config file
|
||||
if ($include_groups && isset($object['config']['ldap_group_member_attrib']))
|
||||
{
|
||||
$result['groups'] = mb_strtolower($object['config']['ldap_group_member_attrib']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// Returns an associative array from the result of an LDAP search
|
||||
private static function getResult($ldap, $entry, array $attributes) : array
|
||||
{
|
||||
// Initialise all keys in the user array, in case an attribute isn't present
|
||||
$attributes_keys = array_keys($attributes);
|
||||
$user = array();
|
||||
foreach ($attributes_keys as $key)
|
||||
{
|
||||
switch ($key)
|
||||
{
|
||||
case 'username':
|
||||
case 'display_name':
|
||||
case 'email':
|
||||
$user[$key] = null;
|
||||
break;
|
||||
case 'groups':
|
||||
$user[$key] = array();
|
||||
break;
|
||||
default:
|
||||
throw new Exception("Unknown key '$key'");
|
||||
}
|
||||
}
|
||||
|
||||
$attribute = ldap_first_attribute($ldap, $entry);
|
||||
|
||||
// Loop through all the attributes for this user
|
||||
while ($attribute)
|
||||
{
|
||||
$values = ldap_get_values($ldap, $entry, $attribute);
|
||||
$attribute = mb_strtolower($attribute); // ready for the comparisons
|
||||
|
||||
if ($attribute == $attributes['username'])
|
||||
{
|
||||
$user['username'] = $values[0];
|
||||
}
|
||||
elseif (isset($attributes['display_name']) && ($attribute == $attributes['display_name']))
|
||||
{
|
||||
$user['display_name'] = $values[0];
|
||||
}
|
||||
elseif (isset($attributes['email']) && ($attribute == $attributes['email']))
|
||||
{
|
||||
$user['email'] = $values[0];
|
||||
}
|
||||
elseif (isset($attributes['groups']) && ($attribute == $attributes['groups']))
|
||||
{
|
||||
for ($i=0; $i<$values['count']; $i++)
|
||||
{
|
||||
$user['groups'][] = $values[$i];
|
||||
}
|
||||
}
|
||||
|
||||
$attribute = ldap_next_attribute($ldap, $entry);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
/* action($callback, $username, &$object)
|
||||
*
|
||||
* Connects/binds to all configured LDAP servers/base DNs and
|
||||
* then performs a callback, passing the LDAP object, $base_dn,
|
||||
* user DN (in $dn), $username and a generic object $object
|
||||
*
|
||||
* $callback - The callback function
|
||||
* $username - The user name
|
||||
* &$object - Reference to the generic object, type defined by caller
|
||||
* $keep_going - Don't stop when a user has been found, but keep going through all the LDAP
|
||||
* hosts. Useful, for example, when you want to get a list of all users.
|
||||
*
|
||||
* Returns:
|
||||
* boolean - Whether the action was successful
|
||||
*/
|
||||
public function action(string $callback, string $username, &$object, bool $keep_going=false) : bool
|
||||
{
|
||||
global $ldap_unbind_between_attempts;
|
||||
|
||||
$result = false;
|
||||
|
||||
for ($idx=0; $idx < count(self::$all_ldap_opts['ldap_host']); $idx++)
|
||||
{
|
||||
// Establish LDAP connection
|
||||
$uri = self::getUri($idx);
|
||||
$ldap = ldap_connect($uri);
|
||||
|
||||
// Check that connection was established
|
||||
if ($ldap)
|
||||
{
|
||||
self::debug("got LDAP connection using $uri");
|
||||
|
||||
// Set any applicable LDAP options
|
||||
self::setOptions($ldap, $idx);
|
||||
|
||||
if (isset(self::$all_ldap_opts['ldap_dn_search_attrib'][$idx]))
|
||||
{
|
||||
if (isset(self::$all_ldap_opts['ldap_dn_search_dn'][$idx]) &&
|
||||
isset(self::$all_ldap_opts['ldap_dn_search_password'][$idx]))
|
||||
{
|
||||
// Bind with DN and password
|
||||
self::debug("binding with search_dn and search_password");
|
||||
$res = self::ldapBind($ldap, self::$all_ldap_opts['ldap_dn_search_dn'][$idx],
|
||||
self::$all_ldap_opts['ldap_dn_search_password'][$idx]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Anonymous bind
|
||||
self::debug("binding anonymously");
|
||||
$res = self::ldapBind($ldap);
|
||||
}
|
||||
|
||||
if (!$res)
|
||||
{
|
||||
self::debug("initial bind failed: " . self::ldapError($ldap));
|
||||
}
|
||||
else
|
||||
{
|
||||
self::debug("initial bind was successful");
|
||||
|
||||
$base_dn = self::$all_ldap_opts['ldap_base_dn'][$idx];
|
||||
$filter = "(" . self::$all_ldap_opts['ldap_dn_search_attrib'][$idx] . "=$username)";
|
||||
|
||||
self::debug("searching using base_dn '$base_dn' and filter '$filter'");
|
||||
$res = ldap_search($ldap, $base_dn, $filter);
|
||||
|
||||
if ($res === false)
|
||||
{
|
||||
self::debug("ldap_search failed: ". self::ldapError($ldap));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ldap_count_entries($ldap, $res) == 1)
|
||||
{
|
||||
$entries = ldap_get_entries($ldap, $res);
|
||||
$dn = $entries[0]["dn"];
|
||||
$user_search = "distinguishedName=" . $dn;
|
||||
self::debug("found one entry dn '$dn'");
|
||||
}
|
||||
else
|
||||
{
|
||||
self::debug(ldap_count_entries($ldap, $res) . " entries found, no unique dn");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// construct dn for user
|
||||
$user_search = self::$all_ldap_opts['ldap_user_attrib'][$idx] . "=" . $username;
|
||||
$dn = $user_search . "," . self::$all_ldap_opts['ldap_base_dn'][$idx];
|
||||
|
||||
self::debug("constructed dn '$dn' and " .
|
||||
"user_search '$user_search' using '" .
|
||||
self::$all_ldap_opts['ldap_user_attrib'][$idx] . "'");
|
||||
}
|
||||
|
||||
foreach (self::$config_items as $item)
|
||||
{
|
||||
if (isset(self::$all_ldap_opts[$item][$idx]))
|
||||
{
|
||||
$object['config'][$item] = self::$all_ldap_opts[$item][$idx];
|
||||
}
|
||||
else
|
||||
{
|
||||
$object['config'][$item] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($dn))
|
||||
{
|
||||
self::debug("no DN determined, not calling callback");
|
||||
}
|
||||
else
|
||||
{
|
||||
$res = self::$callback($ldap, self::$all_ldap_opts['ldap_base_dn'][$idx], $dn,
|
||||
$user_search, $username, $object);
|
||||
if ($res)
|
||||
{
|
||||
$result = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ldap_unbind_between_attempts)
|
||||
{
|
||||
self::debug("unbinding from $uri");
|
||||
ldap_unbind($ldap);
|
||||
}
|
||||
|
||||
} // if ($ldap)
|
||||
|
||||
if ($result && !$keep_going)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} // for ()
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// A wrapper for ldap_bind() that optionally suppresses "invalid credentials" errors.
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
private static function ldapBind (
|
||||
$link_identifier,
|
||||
?string $bind_rdn=null,
|
||||
#[\SensitiveParameter]
|
||||
?string $bind_password=null
|
||||
) : bool
|
||||
{
|
||||
global $ldap_suppress_invalid_credentials;
|
||||
|
||||
// Suppress all errors and then look to see what the error was and then
|
||||
// trigger the error again, depending on config settings.
|
||||
$result = @ldap_bind($link_identifier, $bind_rdn, $bind_password);
|
||||
|
||||
if (!$result)
|
||||
{
|
||||
$errno = ldap_errno($link_identifier);
|
||||
if (!$ldap_suppress_invalid_credentials || ($errno != self::LDAP_INVALID_CREDENTIALS))
|
||||
{
|
||||
trigger_error(ldap_err2str($errno), E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// Gets the full LDAP URI
|
||||
private static function getUri(int $idx) : string
|
||||
{
|
||||
// First get the scheme and host
|
||||
$host = self::$all_ldap_opts['ldap_host'][$idx];
|
||||
$parsed_url = parse_url($host);
|
||||
if (isset($parsed_url['scheme']))
|
||||
{
|
||||
$scheme = $parsed_url['scheme'];
|
||||
$host = $parsed_url['host'];
|
||||
}
|
||||
|
||||
// If we haven't got a scheme then make an educated guess based on the port.
|
||||
if (!isset($scheme))
|
||||
{
|
||||
// And if there isn't a port defined either then use a sensible default
|
||||
$port = self::$all_ldap_opts['ldap_port'][$idx] ?? self::DEFAULT_PORT_LDAP;
|
||||
$scheme = ($port == self::DEFAULT_PORT_LDAPS) ? 'ldaps' : 'ldap';
|
||||
}
|
||||
// If we have got a scheme then get the port. If it has been defined explicitly in the
|
||||
// config file, then use that. Otherwise make an educated guess based on the scheme.
|
||||
else
|
||||
{
|
||||
if (isset(self::$all_ldap_opts['ldap_port'][$idx]))
|
||||
{
|
||||
$port = self::$all_ldap_opts['ldap_port'][$idx];
|
||||
}
|
||||
else
|
||||
{
|
||||
$port = ($scheme == 'ldaps') ? self::DEFAULT_PORT_LDAPS : self::DEFAULT_PORT_LDAP;
|
||||
}
|
||||
}
|
||||
|
||||
return "$scheme://$host:$port";
|
||||
}
|
||||
|
||||
|
||||
private static function setOptions($ldap, int $idx) : void
|
||||
{
|
||||
if (isset(self::$all_ldap_opts['ldap_deref'][$idx]))
|
||||
{
|
||||
ldap_set_option($ldap, LDAP_OPT_DEREF, self::$all_ldap_opts['ldap_deref'][$idx]);
|
||||
}
|
||||
|
||||
if (isset(self::$all_ldap_opts['ldap_v3'][$idx]) &&
|
||||
self::$all_ldap_opts['ldap_v3'][$idx])
|
||||
{
|
||||
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
|
||||
}
|
||||
|
||||
if (isset(self::$all_ldap_opts['ldap_client_cert'][$idx]) &&
|
||||
self::$all_ldap_opts['ldap_client_cert'][$idx])
|
||||
{
|
||||
// Requires PHP 7.1.0 or later
|
||||
ldap_set_option($ldap, LDAP_OPT_X_TLS_CERTFILE, self::$all_ldap_opts['ldap_client_cert'][$idx]);
|
||||
}
|
||||
|
||||
if (isset(self::$all_ldap_opts['ldap_client_key'][$idx]) &&
|
||||
self::$all_ldap_opts['ldap_client_key'][$idx])
|
||||
{
|
||||
// Requires PHP 7.1.0 or later
|
||||
ldap_set_option($ldap, LDAP_OPT_X_TLS_KEYFILE, self::$all_ldap_opts['ldap_client_key'][$idx]);
|
||||
}
|
||||
|
||||
if (isset(self::$all_ldap_opts['ldap_tls'][$idx]) &&
|
||||
self::$all_ldap_opts['ldap_tls'][$idx])
|
||||
{
|
||||
ldap_start_tls($ldap);
|
||||
}
|
||||
|
||||
if (isset(self::$all_ldap_opts['ldap_disable_referrals'][$idx]) &&
|
||||
self::$all_ldap_opts['ldap_disable_referrals'][$idx])
|
||||
{
|
||||
// Required to do a search on Active Directory for Win 2003+
|
||||
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Adds extra diagnostic information to ldap_error()
|
||||
private static function ldapError ($link_identifier) : string
|
||||
{
|
||||
$result = ldap_error($link_identifier);
|
||||
|
||||
// LDAP_OPT_DIAGNOSTIC_MESSAGE is not supported by all LDAP libraries
|
||||
if (defined('LDAP_OPT_DIAGNOSTIC_MESSAGE') &&
|
||||
ldap_get_option($link_identifier, LDAP_OPT_DIAGNOSTIC_MESSAGE, $err) &&
|
||||
isset($err) && ($err !== ''))
|
||||
{
|
||||
$result .= " [$err]";
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/* debug($message)
|
||||
*
|
||||
* Output LDAP debugging, if either of the configuration variables
|
||||
* $ldap_debug or $ldap_debug_attributes is true.
|
||||
*
|
||||
*/
|
||||
private static function debug(string $message) : void
|
||||
{
|
||||
global $ldap_debug, $ldap_debug_attributes;
|
||||
|
||||
if ($ldap_debug || $ldap_debug_attributes)
|
||||
{
|
||||
self::logDebugMessage($message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static function getProfileClock()
|
||||
{
|
||||
global $ldap_debug;
|
||||
|
||||
if ($ldap_debug)
|
||||
{
|
||||
return (get_microtime() - self::$profile_clock);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static function resetProfileClock() : void
|
||||
{
|
||||
global $ldap_debug;
|
||||
|
||||
if ($ldap_debug)
|
||||
{
|
||||
self::$profile_clock = get_microtime();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
|
||||
class AuthNone extends Auth
|
||||
{
|
||||
/**
|
||||
* Checks if the specified username/password pair are valid.
|
||||
*
|
||||
* This authentication scheme always validates positively.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
|
||||
class AuthNw extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// Check if we do not have a username/password
|
||||
if (empty($user) || empty($pass))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate the command line
|
||||
$cmd = $auth["prog"] . " -S " . $auth["params"] . " -U '$user'";
|
||||
|
||||
// Run the program, sending the password to stdin.
|
||||
$p = popen($cmd, "w");
|
||||
|
||||
if (!$p)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fputs($p, $pass);
|
||||
|
||||
if (pclose($p) == 0)
|
||||
{
|
||||
return $user;
|
||||
}
|
||||
|
||||
// return failure
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\Exception;
|
||||
|
||||
/**
|
||||
* Authentication scheme that uses POP3 as the source for user authentication.
|
||||
*
|
||||
* To use this authentication scheme, set the following things in config.inc.php:
|
||||
*
|
||||
* $auth["realm"] = "MRBS"; // Or any other string
|
||||
* $auth["type"] = "pop3";
|
||||
*
|
||||
* Then, you may configure admin users:
|
||||
*
|
||||
* $auth["admin"][] = "pop3user1";
|
||||
* $auth["admin"][] = "pop3user2";
|
||||
*/
|
||||
class AuthPop3 extends Auth
|
||||
{
|
||||
private const CONNECT_TIMEOUT = 15; // seconds
|
||||
private const STREAM_TIMEOUT = 15; // seconds
|
||||
|
||||
private $hosts;
|
||||
private $ports;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
global $pop3_host, $pop3_port;
|
||||
|
||||
// Build an array of hosts and ports from the config settings
|
||||
$this->hosts = array();
|
||||
$this->ports = array();
|
||||
|
||||
// Check that if there is an array of hosts and an array of ports
|
||||
// then the number of each is the same
|
||||
if (is_array($pop3_host) && is_array($pop3_port) &&
|
||||
(count($pop3_port) != count($pop3_host)))
|
||||
{
|
||||
$message = "MRBS config error: number of POP3 hosts does not match number of POP3 ports.";
|
||||
throw new Exception($message);
|
||||
}
|
||||
|
||||
// Transfer the list of POP3 hosts to a new value to ensure that an array is always used.
|
||||
// If a single value is passed then turn it into an array
|
||||
$this->hosts = (is_array($pop3_host)) ? $pop3_host : array($pop3_host);
|
||||
|
||||
// Create an array of the port numbers to match the number of
|
||||
// hosts if a single port number has been passed.
|
||||
$this->ports = (is_array($pop3_port)) ? $pop3_port : array_pad($this->ports, count($this->hosts), $pop3_port);
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
// Check if we do not have a username/password
|
||||
if (!isset($user) || !isset($pass) || strlen($pass)==0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// iterate over all hosts and return if you get a successful login
|
||||
foreach ($this->hosts as $i => $host)
|
||||
{
|
||||
$port = $this->ports[$i];
|
||||
// Connect to POP3 server
|
||||
$stream = fsockopen($host, $port, $error_number, $error_string, self::CONNECT_TIMEOUT);
|
||||
if ($stream === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, self::STREAM_TIMEOUT);
|
||||
$response = fgets($stream, 1024);
|
||||
if ($response === false)
|
||||
{
|
||||
trigger_error("fgets() failed using host '$host' and port '$port'", E_USER_WARNING);
|
||||
continue;
|
||||
}
|
||||
|
||||
// First we try to use APOP, and then if that fails we fall back to
|
||||
// traditional stuff
|
||||
|
||||
// Get the shared secret ( something on the greeting line that looks like <XXXX> )
|
||||
if (preg_match('/(<[^>]*>)/', $response, $match))
|
||||
{
|
||||
$shared_secret = $match[0];
|
||||
}
|
||||
|
||||
// If we have a shared secret then try APOP
|
||||
if (isset($shared_secret) && ($shared_secret !== ''))
|
||||
{
|
||||
$md5_token = md5("$shared_secret$pass");
|
||||
$auth_string = "APOP $user $md5_token\r\n";
|
||||
fputs($stream, $auth_string);
|
||||
|
||||
// Read the response. If it's an OK then we're authenticated
|
||||
$response = fgets($stream, 1024);
|
||||
if (str_starts_with($response, '+OK'))
|
||||
{
|
||||
fputs($stream, "QUIT\r\n");
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've still not authenticated then try using traditional methods.
|
||||
// Need to reconnect if we tried APOP
|
||||
$stream = fsockopen($host, $port, $error_number, $error_string, self::CONNECT_TIMEOUT);
|
||||
|
||||
if ($stream === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, self::STREAM_TIMEOUT);
|
||||
// Send standard POP3 USER and PASS commands
|
||||
fputs($stream, "USER $user\r\n");
|
||||
$response = fgets($stream, 1024);
|
||||
if (str_starts_with($response, '+OK'))
|
||||
{
|
||||
fputs($stream, "PASS $pass\r\n");
|
||||
$response = fgets($stream, 1024);
|
||||
if (str_starts_with($response, '+OK'))
|
||||
{
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
fputs($stream, "QUIT\r\n");
|
||||
}
|
||||
|
||||
// Return failure
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByEmail() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\User;
|
||||
|
||||
|
||||
/**
|
||||
* Authentication management scheme that delegates everything to a ready
|
||||
* configured SimpleSamlPhp instance. You should use this scheme, along with
|
||||
* the session scheme with the same name, if you want your users to
|
||||
* authenticate using SAML Single Sign-on.
|
||||
*
|
||||
* See the session management scheme with the same name for information on
|
||||
* how to configure SAML authentication. This authentication module on its
|
||||
* own doesn't work.
|
||||
*/
|
||||
class AuthSaml extends Auth
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->checkSessionMatchesType();
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
$current_username = \MRBS\session()->getUsername();
|
||||
|
||||
if (isset($current_username) && $current_username === $user)
|
||||
{
|
||||
return $user;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
$user = new User($username);
|
||||
$user->level = $this->getLevel($username);
|
||||
$user->email = $this->getEmail($username);
|
||||
$user->display_name = $this->getUserDisplayName($username);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
/* getLevel($username)
|
||||
*
|
||||
* Determines the user's access level
|
||||
*
|
||||
* It does this by comparing SAML attributes with $auth['saml']['admin']
|
||||
* If any attribute matches, the user is considered admin and 2 is returned.
|
||||
*
|
||||
* If the user is not logged in, or the provided username doesn't match our
|
||||
* SAML session, 0 is returned.
|
||||
*
|
||||
* Otherwise, 1 is returned.
|
||||
*
|
||||
* $username - The user name
|
||||
*
|
||||
* Returns:
|
||||
* The user's access level
|
||||
*/
|
||||
private function getLevel(string $username) : int
|
||||
{
|
||||
global $auth;
|
||||
|
||||
if (!isset($auth['saml']['admin']))
|
||||
{
|
||||
return $this->getDefaultLevel($username);
|
||||
}
|
||||
|
||||
$userData = \MRBS\session()->ssp->getAttributes();
|
||||
$current_username = \MRBS\session()->getUsername();
|
||||
|
||||
if (isset($current_username) && $current_username === $username)
|
||||
{
|
||||
foreach ($auth['saml']['admin'] as $attr => $values)
|
||||
{
|
||||
if (array_key_exists($attr, $userData))
|
||||
{
|
||||
foreach ($values as $value)
|
||||
{
|
||||
if (in_array($value, $userData[$attr]))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($auth['saml']['user']))
|
||||
{
|
||||
foreach ($auth['saml']['user'] as $attr => $values)
|
||||
{
|
||||
if (array_key_exists($attr, $userData))
|
||||
{
|
||||
foreach ($values as $value)
|
||||
{
|
||||
if (in_array($value, $userData[$attr]))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Gets the users e-mail from the SAML attributes.
|
||||
// Returns an empty string if no e-mail address was found
|
||||
private function getEmail(string $username) : string
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$mailAttr = $auth['saml']['attr']['mail'];
|
||||
$userData = \MRBS\session()->ssp->getAttributes();
|
||||
$current_username = \MRBS\session()->getUsername();
|
||||
|
||||
if (isset($current_username) && $current_username === $username)
|
||||
{
|
||||
return array_key_exists($mailAttr, $userData) ? $userData[$mailAttr][0] : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Gets the users displayname from the SAML attributes.
|
||||
// Returns an empty string if no givenName and surname was found
|
||||
private function getUserDisplayName(string $username) : string
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$givenNameAttr = $auth['saml']['attr']['givenName'];
|
||||
$surnameAttr = $auth['saml']['attr']['surname'];
|
||||
$userData = \MRBS\session()->ssp->getAttributes();
|
||||
$current_username = \MRBS\session()->getUsername();
|
||||
|
||||
if (isset($current_username) && $current_username === $username)
|
||||
{
|
||||
$givenName = array_key_exists($givenNameAttr, $userData) ? $userData[$givenNameAttr][0] : '';
|
||||
$surname = array_key_exists($surnameAttr, $userData) ? $userData[$surnameAttr][0] : '';
|
||||
return trim($givenName . ' ' . $surname);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\User;
|
||||
|
||||
/**
|
||||
* A class for authenticating against a Wix system. It requires code to be installed in
|
||||
* http-functions.js in the Wix backend. See wix/README for full details.
|
||||
*
|
||||
* It would be nice to be able to use Wix's OAuth2 server, but it seems to be limited to Wix apps.
|
||||
*
|
||||
* Another approach might be to use the Wix backend function getMember(), but this doesn't work inside
|
||||
* http_functions as the URL endpoint for http_functions is not associated with a user session.
|
||||
*/
|
||||
class AuthWix extends Auth
|
||||
{
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
if (!isset($user) || !isset($pass))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$params = array(
|
||||
'email' => $user,
|
||||
'password' => $pass
|
||||
);
|
||||
|
||||
$result = $this->http_functions('validateMember', $params);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
// curl_exec failure: we'll return false anyway
|
||||
return $result;
|
||||
}
|
||||
|
||||
return (json_decode($result)) ? $user : false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByEmail() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByUsername() : bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$params = array('email' => $username);
|
||||
$result = $this->http_functions('getMemberByEmail', $params);
|
||||
|
||||
// The username doesn't exist - return NULL
|
||||
if (($result === false) || ($result === '') || ($result === json_encode(null)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = json_decode($result);
|
||||
|
||||
$user = new User($username);
|
||||
|
||||
// Set the email address
|
||||
$user->email = $result->member->loginEmail;
|
||||
|
||||
// Set the display name
|
||||
$property = $auth['wix']['display_name_property'] ?? 'name';
|
||||
if (isset($result->member->$property) && ($result->member->$property !== ''))
|
||||
{
|
||||
$user->display_name = $result->member->$property;
|
||||
}
|
||||
else
|
||||
{
|
||||
$user->display_name = $result->member->loginEmail;
|
||||
}
|
||||
|
||||
// Set the level
|
||||
// First get the default level. Any admins defined in the config
|
||||
// file override settings in the external database.
|
||||
$user->level = $this->getDefaultLevel($username);
|
||||
|
||||
// Then if they are not an admin get their admin status from Wix
|
||||
if (($user->level < 2) && isset($result->badges) && in_array($auth['wix']['admin_badge'], $result->badges))
|
||||
{
|
||||
$user->level = 2;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
// Return an array of users, indexed by 'username' and 'display_name'
|
||||
public function getUsernames() : array
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$params = array();
|
||||
|
||||
if (isset($auth['wix']['display_name_property']))
|
||||
{
|
||||
$params['display_name_property'] = $auth['wix']['display_name_property'];
|
||||
}
|
||||
|
||||
$result = $this->http_functions('getMemberNames', $params);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
$users = json_decode($result, true);
|
||||
|
||||
self::sortUsers($users);
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
|
||||
private function http_functions(string $function, array $params)
|
||||
{
|
||||
global $auth, $server;
|
||||
|
||||
// Add a trailing '/' if necessary
|
||||
if (!str_ends_with($auth['wix']['site_url'], '/'))
|
||||
{
|
||||
$auth['wix']['site_url'] .= '/';
|
||||
}
|
||||
|
||||
// Get a user agent to keep the other end happy
|
||||
if (isset($server['HTTP_USER_AGENT']) && ($server['HTTP_USER_AGENT'] !== ''))
|
||||
{
|
||||
$user_agent = $server['HTTP_USER_AGENT'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$user_agent = 'PHP';
|
||||
}
|
||||
|
||||
// Add in the API key
|
||||
$params['key'] = $auth['wix']['mrbs_api_key'];
|
||||
// And the API key secret name in Wix
|
||||
$params['secret_name'] = $auth['wix']['mrbs_api_key_secret_name'];
|
||||
|
||||
// And the limit, for internal use by the Wix backend code
|
||||
if (isset($auth['wix']['limit']))
|
||||
{
|
||||
$params['limit'] = $auth['wix']['limit'];
|
||||
}
|
||||
|
||||
$url = $auth['wix']['site_url'] . "_functions/$function";
|
||||
self::debug("URL=\"$url\"");
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
// Necessary to prevent "HTTP/2 stream 0 was not closed cleanly: INTERNAL_ERROR (err 2)" error;
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
// Necessary to prevent "OpenSSL SSL_read: Connection reset by peer, errno 104" error;
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, $user_agent);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
|
||||
// Use compression if we can
|
||||
if (defined('CURLOPT_ENCODING'))
|
||||
{
|
||||
curl_setopt($ch, CURLOPT_ENCODING, '');
|
||||
}
|
||||
// Get some debug info in case there's an error
|
||||
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
$stream = fopen('php://temp', 'w+');
|
||||
curl_setopt($ch, CURLOPT_STDERR, $stream);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
rewind($stream);
|
||||
$message = curl_error($ch);
|
||||
$message .= "\n\nCurl verbose log:\n\n" . stream_get_contents($stream);
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
}
|
||||
elseif ($http_code != 200)
|
||||
{
|
||||
trigger_error("Curl received HTTP response code $http_code: $result", E_USER_WARNING);
|
||||
$result = false;
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
|
||||
// curl_close() doesn't do anything from PHP 8.0 onwards (because the curl
|
||||
// handle is an object and not a resource) and is deprecated from PHP 8.5.
|
||||
assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "The code below is now redundant.");
|
||||
if (version_compare(PHP_VERSION, '8.0.0') < 0)
|
||||
{
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
private static function debug(string $message) : void
|
||||
{
|
||||
global $auth;
|
||||
|
||||
if ($auth['wix']['debug'])
|
||||
{
|
||||
self::logDebugMessage($message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
namespace MRBS\Auth;
|
||||
|
||||
use MRBS\User;
|
||||
|
||||
require_once MRBS_ROOT . '/auth/cms/wordpress.inc';
|
||||
|
||||
|
||||
class AuthWordpress extends Auth
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->checkSessionMatchesType();
|
||||
}
|
||||
|
||||
|
||||
public function validateUser(
|
||||
#[\SensitiveParameter]
|
||||
?string $user,
|
||||
#[\SensitiveParameter]
|
||||
?string $pass)
|
||||
{
|
||||
return (is_wp_error(wp_authenticate($user, $pass))) ? false : $user;
|
||||
}
|
||||
|
||||
|
||||
protected function getUserFresh(string $username) : ?User
|
||||
{
|
||||
$wp_user = get_user_by('login', $username);
|
||||
|
||||
if ($wp_user === false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = new User($username);
|
||||
$user->display_name = $wp_user->display_name;
|
||||
$user->email = $wp_user->user_email;
|
||||
$user->level = self::getUserLevel($wp_user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*/
|
||||
public function canValidateByEmail() : bool
|
||||
{
|
||||
// In the case of WordPress, wp_authenticate() accepts either
|
||||
// a username or email address and so this function always returns true.
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Return an array of MRBS users, indexed by 'username' and 'display_name'
|
||||
public function getUsernames() : array
|
||||
{
|
||||
global $auth;
|
||||
|
||||
$result = array();
|
||||
|
||||
// We are only interested in MRBS users and admins
|
||||
$mrbs_roles = array_merge((array)$auth['wordpress']['admin_roles'],
|
||||
(array)$auth['wordpress']['user_roles']);
|
||||
|
||||
// The 'role__in' argument to get_users() is only supported in Wordpress >= 4.4.
|
||||
// Before that we have to do it one role at a time with the 'role' argument.
|
||||
$can_use_role__in = version_compare(get_bloginfo('version'), '4.4', '>=');
|
||||
|
||||
$args = array('fields' => array('user_login', 'display_name'),
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC');
|
||||
|
||||
if ($can_use_role__in)
|
||||
{
|
||||
$args['role__in'] = $mrbs_roles;
|
||||
$users = get_users($args);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
$users = array();
|
||||
$mrbs_roles = array_unique($mrbs_roles);
|
||||
foreach ($mrbs_roles as $mrbs_role)
|
||||
{
|
||||
$args['role'] = $mrbs_role;
|
||||
$users = array_merge($users, get_users($args));
|
||||
}
|
||||
// Remove duplicate users
|
||||
$users = array_map('unserialize', array_unique(array_map('serialize', $users)));
|
||||
}
|
||||
|
||||
foreach ($users as $user)
|
||||
{
|
||||
$result[] = array('username' => $user->user_login,
|
||||
'display_name' => $user->display_name);
|
||||
}
|
||||
|
||||
// Although the users are probably already sorted, we sort them again because MRBS
|
||||
// offers an option for sorting by first or last name.
|
||||
self::sortUsers($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
private static function getUserLevel(\WP_User $wp_user) : int
|
||||
{
|
||||
global $auth;
|
||||
|
||||
// cache the user levels for performance
|
||||
static $user_levels = array();
|
||||
|
||||
// User not logged in, user level '0'
|
||||
// Shouldn't get here anyway because the type hint won't allow it,
|
||||
// but we'll check anyway for completeness
|
||||
if(!isset($wp_user) || ($wp_user === false))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isset($user_levels[$wp_user->login]))
|
||||
{
|
||||
// Check to see if one of the user's roles is an MRBS blacklisted role
|
||||
if (isset($auth['wordpress']['blacklisted_roles']) &&
|
||||
self::check_roles($wp_user, $auth['wordpress']['blacklisted_roles']))
|
||||
{
|
||||
$user_levels[$wp_user->login] = 0;
|
||||
}
|
||||
// Check to see if one of the user's roles is an MRBS admin role
|
||||
elseif (isset($auth['wordpress']['admin_roles']) &&
|
||||
self::check_roles($wp_user, $auth['wordpress']['admin_roles']))
|
||||
{
|
||||
$user_levels[$wp_user->login] = 2;
|
||||
}
|
||||
// Check to see if one of the user's roles is an MRBS user role
|
||||
elseif (isset($auth['wordpress']['user_roles']) &&
|
||||
self::check_roles($wp_user, $auth['wordpress']['user_roles']))
|
||||
{
|
||||
$user_levels[$wp_user->login] = 1;
|
||||
}
|
||||
// Everybody else is access level '0'
|
||||
else
|
||||
{
|
||||
$user_levels[$wp_user->login] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $user_levels[$wp_user->login];
|
||||
}
|
||||
|
||||
|
||||
// Checks to see whether any of the user's roles are contained in $mrbs_roles, which can be a
|
||||
// string or an array of strings.
|
||||
private static function check_roles(\WP_User $wp_user, $mrbs_roles) : bool
|
||||
{
|
||||
if (!isset($mrbs_roles))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Turn $mrbs_roles into an array if it isn't already
|
||||
$mrbs_roles = (array)$mrbs_roles;
|
||||
|
||||
// Put the roles into the standard WordPress format
|
||||
$mrbs_roles = array_map(self::class . '::standardise_role_name', $mrbs_roles);
|
||||
|
||||
return (count(array_intersect($wp_user->roles, $mrbs_roles)) > 0);
|
||||
}
|
||||
|
||||
|
||||
// Convert a WordPress role name to lowercase and replace spaces by underscores.
|
||||
// Example "MRBS Admin" -> "mrbs_admin"
|
||||
private static function standardise_role_name(string $role) : string
|
||||
{
|
||||
return str_replace(' ', '_', mb_strtolower($role));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user