BookStack/app/Auth/Access/LdapService.php
Dan Brown 3d8df952b7
Combined LDAP connect and bind for failover handling
Not totally happy with the complexity in approach, could maybe do with
refactoring some of this out, but getting a little tired spending time
on this.
2022-10-17 17:46:14 +01:00

485 lines
16 KiB
PHP

<?php
namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LdapFailedBindException;
use BookStack\Uploads\UserAvatars;
use ErrorException;
use Illuminate\Support\Facades\Log;
/**
* Class LdapService
* Handles any app-specific LDAP tasks.
*/
class LdapService
{
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
protected array $config;
protected bool $enabled;
/**
* LdapService constructor.
*/
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
$this->enabled = config('auth.method') === 'ldap';
}
/**
* Check if groups should be synced.
*/
public function shouldSyncGroups(): bool
{
return $this->enabled && $this->config['user_to_groups'] !== false;
}
/**
* Search for attributes for a specific user on the ldap.
*
* @throws LdapException
*/
protected function getUserWithAttributes(string $userName, array $attributes): ?array
{
$ldapConnection = $this->bindConnection();
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
// Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn'];
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
$users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
if ($users['count'] === 0) {
return null;
}
return $users[0];
}
/**
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
*
* @throws LdapException
*/
public function getUserDetails(string $userName): ?array
{
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute'];
$displayNameAttr = $this->config['display_name_attribute'];
$thumbnailAttr = $this->config['thumbnail_attribute'];
$user = $this->getUserWithAttributes($userName, array_filter([
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
]));
if (is_null($user)) {
return null;
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
];
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'details_bookstack_parsed' => $formatted,
]);
}
return $formatted;
}
/**
* Get a property from an LDAP user response fetch.
* Handles properties potentially being part of an array.
* If the given key is prefixed with 'BIN;', that indicator will be stripped
* from the key and any fetched values will be converted from binary to hex.
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
$isBinary = strpos($propertyKey, 'BIN;') === 0;
$propertyKey = strtolower($propertyKey);
$value = $defaultValue;
if ($isBinary) {
$propertyKey = substr($propertyKey, strlen('BIN;'));
}
if (isset($userDetails[$propertyKey])) {
$value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
if ($isBinary) {
$value = bin2hex($value);
}
}
return $value;
}
/**
* Check if the given credentials are valid for the given user.
*
* @throws LdapException
*/
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
{
if (is_null($ldapUserDetails)) {
return false;
}
try {
$this->bindConnection($ldapUserDetails['dn'], $password);
} catch (LdapFailedBindException $e) {
return false;
} catch (LdapException $e) {
throw $e;
}
return true;
}
/**
* Attempted to start and bind to a new LDAP connection.
* Will attempt against multiple defined fail-over hosts if set.
*
* Throws a LdapFailedBindException error if the bind connected but failed.
* Otherwise, generic LdapException errors would be thrown.
*
* @return resource
* @throws LdapException
*/
protected function bindConnection(string $dn = null, string $password = null)
{
$systemBind = ($dn === null && $password === null);
// Check LDAP extension in installed
if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
throw new LdapException(trans('errors.ldap_extension_not_installed'));
}
// Disable certificate verification.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_insecure']) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
$serverDetails = $this->parseMultiServerString($this->config['server']);
$lastException = null;
foreach ($serverDetails as $server) {
try {
$connection = $this->startServerConnection($server);
} catch (LdapException $exception) {
$lastException = $exception;
continue;
}
try {
if ($systemBind) {
$this->bindSystemUser($connection);
} else {
$this->bindGivenUser($connection, $dn, $password);
}
} catch (LdapFailedBindException $exception) {
// Rethrow simply to indicate the importance of handling this exception case
// to indicate auth status. We skip past attempting fail-over hosts in this case since it's
// likely the connection worked here but the bind was unauthorised.
throw $exception;
} catch (ErrorException $exception) {
Log::error('LDAP bind error: ' . $exception->getMessage());
$lastException = new LdapException('Encountered error during LDAP bind');
continue;
}
return $connection;
}
throw $lastException;
}
/**
* Bind to the given LDAP connection using the given credentials.
* MUST throw an exception on failure.
*
* @param resource $connection
*
* @throws LdapFailedBindException
*/
protected function bindGivenUser($connection, string $dn = null, string $password = null): void
{
$ldapBind = $this->ldap->bind($connection, $dn, $password);
if (!$ldapBind) {
throw new LdapFailedBindException('Failed to bind with given user details');
}
}
/**
* Bind the system user to the LDAP connection using the configured credentials otherwise anonymous
* access is attempted. MUST throw an exception on failure.
*
* @param resource $connection
*
* @throws LdapFailedBindException
*/
protected function bindSystemUser($connection): void
{
$ldapDn = $this->config['dn'];
$ldapPass = $this->config['pass'];
$isAnonymous = ($ldapDn === false || $ldapPass === false);
if ($isAnonymous) {
$ldapBind = $this->ldap->bind($connection);
} else {
$ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
}
if (!$ldapBind) {
throw new LdapFailedBindException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
}
}
/**
* Attempt to start a server connection from the provided details.
*
* @param array{host: string, port: int} $serverDetail
* @return resource
* @throws LdapException
*/
protected function startServerConnection(array $serverDetail)
{
$ldapConnection = $this->ldap->connect($serverDetail['host'], $serverDetail['port']);
if (!$ldapConnection) {
throw new LdapException(trans('errors.ldap_cannot_connect'));
}
// Set any required options
if ($this->config['version']) {
$this->ldap->setVersion($ldapConnection, $this->config['version']);
}
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
try {
$tlsStarted = $this->ldap->startTls($ldapConnection);
} catch (ErrorException $exception) {
$tlsStarted = false;
}
if (!$tlsStarted) {
throw new LdapException('Could not start TLS connection');
}
}
return $ldapConnection;
}
/**
* Parse a potentially multi-value LDAP server host string and return an array of host/port detail pairs.
* Multiple hosts are separated with a semicolon, for example: 'ldap.example.com:8069;ldaps://ldap.example.com'
*
* @return array<array{host: string, port: int}>
*/
protected function parseMultiServerString(string $serversString): array
{
$serverStringList = explode(';', $serversString);
return array_map(fn ($serverStr) => $this->parseSingleServerString($serverStr), $serverStringList);
}
/**
* Parse an LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*
* @return array{host: string, port: int}
*/
protected function parseSingleServerString(string $serverString): array
{
$serverNameParts = explode(':', $serverString);
// If we have a protocol just return the full string since PHP will ignore a separate port.
if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
return ['host' => $serverString, 'port' => 389];
}
// Otherwise, extract the port out
$hostName = $serverNameParts[0];
$ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
return ['host' => $hostName, 'port' => $ldapPort];
}
/**
* Build a filter string by injecting common variables.
*/
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
}
return strtr($filterString, $newAttrs);
}
/**
* Get the groups a user is a part of on ldap.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function getUserGroups(string $userName): array
{
$groupsAttr = $this->config['group_attribute'];
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
if ($user === null) {
return [];
}
$userGroups = $this->groupFilter($user);
$allGroups = $this->getGroupsRecursive($userGroups, []);
if ($this->config['dump_user_groups']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
'parsed_recursive_user_groups' => $allGroups,
]);
}
return $allGroups;
}
/**
* Get the parent groups of an array of groups.
*
* @throws LdapException
*/
private function getGroupsRecursive(array $groupsArray, array $checked): array
{
$groupsToAdd = [];
foreach ($groupsArray as $groupName) {
if (in_array($groupName, $checked)) {
continue;
}
$parentGroups = $this->getGroupGroups($groupName);
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
$checked[] = $groupName;
}
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
if (empty($groupsToAdd)) {
return $groupsArray;
}
return $this->getGroupsRecursive($groupsArray, $checked);
}
/**
* Get the parent groups of a single group.
*
* @throws LdapException
*/
private function getGroupGroups(string $groupName): array
{
$ldapConnection = $this->bindConnection();
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
$baseDn = $this->config['base_dn'];
$groupsAttr = strtolower($this->config['group_attribute']);
$groupFilter = 'CN=' . $this->ldap->escape($groupName);
$groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
if ($groups['count'] === 0) {
return [];
}
return $this->groupFilter($groups[0]);
}
/**
* Filter out LDAP CN and DN language in a ldap search return.
* Gets the base CN (common name) of the string.
*/
protected function groupFilter(array $userGroupSearchResponse): array
{
$groupsAttr = strtolower($this->config['group_attribute']);
$ldapGroups = [];
$count = 0;
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
}
for ($i = 0; $i < $count; $i++) {
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
if (!in_array($dnComponents[0], $ldapGroups)) {
$ldapGroups[] = $dnComponents[0];
}
}
return $ldapGroups;
}
/**
* Sync the LDAP groups to the user roles for the current user.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
}
/**
* Save and attach an avatar image, if found in the ldap details, and attach
* to the given user model.
*/
public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
{
if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
return;
}
try {
$imageData = $ldapUserDetails['avatar'];
$this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
} catch (\Exception $exception) {
Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
}
}
}