1 <?php namespace BookStack\Services;
3 use BookStack\Exceptions\LdapException;
4 use BookStack\Repos\UserRepo;
7 use Illuminate\Contracts\Auth\Authenticatable;
11 * Handles any app-specific LDAP tasks.
12 * @package BookStack\Services
18 protected $ldapConnection;
24 * LdapService constructor.
26 * @param UserRepo $userRepo
28 public function __construct(Ldap $ldap, UserRepo $userRepo)
31 $this->config = config('services.ldap');
32 $this->userRepo = $userRepo;
33 $this->enabled = config('auth.method') === 'ldap';
37 * Check if groups should be synced.
40 public function shouldSyncGroups()
42 return $this->enabled && $this->config['user_to_groups'] !== false;
46 * Search for attributes for a specific user on the ldap
47 * @param string $userName
48 * @param array $attributes
50 * @throws LdapException
52 private function getUserWithAttributes($userName, $attributes)
54 $ldapConnection = $this->getConnection();
55 $this->bindSystemUser($ldapConnection);
58 $userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
59 $baseDn = $this->config['base_dn'];
61 $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
62 $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
63 $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
64 if ($users['count'] === 0) {
72 * Get the details of a user from LDAP using the given username.
73 * User found via configurable user filter.
76 * @throws LdapException
78 public function getUserDetails($userName)
80 $emailAttr = $this->config['email_attribute'];
81 $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr]);
88 'uid' => (isset($user['uid'])) ? $user['uid'][0] : $user['dn'],
89 'name' => $user['cn'][0],
91 'email' => (isset($user[$emailAttr])) ? (is_array($user[$emailAttr]) ? $user[$emailAttr][0] : $user[$emailAttr]) : null
96 * @param Authenticatable $user
97 * @param string $username
98 * @param string $password
100 * @throws LdapException
102 public function validateUserCredentials(Authenticatable $user, $username, $password)
104 $ldapUser = $this->getUserDetails($username);
105 if ($ldapUser === null) {
108 if ($ldapUser['uid'] !== $user->external_auth_id) {
112 $ldapConnection = $this->getConnection();
114 $ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
115 } catch (\ErrorException $e) {
123 * Bind the system user to the LDAP connection using the given credentials
124 * otherwise anonymous access is attempted.
126 * @throws LdapException
128 protected function bindSystemUser($connection)
130 $ldapDn = $this->config['dn'];
131 $ldapPass = $this->config['pass'];
133 $isAnonymous = ($ldapDn === false || $ldapPass === false);
135 $ldapBind = $this->ldap->bind($connection);
137 $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
141 throw new LdapException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
146 * Get the connection to the LDAP server.
147 * Creates a new connection if one does not exist.
149 * @throws LdapException
151 protected function getConnection()
153 if ($this->ldapConnection !== null) {
154 return $this->ldapConnection;
157 // Check LDAP extension in installed
158 if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
159 throw new LdapException(trans('errors.ldap_extension_not_installed'));
162 // Get port from server string and protocol if specified.
163 $ldapServer = explode(':', $this->config['server']);
164 $hasProtocol = preg_match('/^ldaps{0,1}\:\/\//', $this->config['server']) === 1;
166 array_unshift($ldapServer, '');
168 $hostName = $ldapServer[0] . ($hasProtocol?':':'') . $ldapServer[1];
169 $defaultPort = $ldapServer[0] === 'ldaps' ? 636 : 389;
170 $ldapConnection = $this->ldap->connect($hostName, count($ldapServer) > 2 ? intval($ldapServer[2]) : $defaultPort);
172 if ($ldapConnection === false) {
173 throw new LdapException(trans('errors.ldap_cannot_connect'));
176 // Set any required options
177 if ($this->config['version']) {
178 $this->ldap->setVersion($ldapConnection, $this->config['version']);
181 $this->ldapConnection = $ldapConnection;
182 return $this->ldapConnection;
186 * Build a filter string by injecting common variables.
187 * @param string $filterString
188 * @param array $attrs
191 protected function buildFilter($filterString, array $attrs)
194 foreach ($attrs as $key => $attrText) {
195 $newKey = '${' . $key . '}';
196 $newAttrs[$newKey] = $attrText;
198 return strtr($filterString, $newAttrs);
202 * Get the groups a user is a part of on ldap
203 * @param string $userName
205 * @throws LdapException
207 public function getUserGroups($userName)
209 $groupsAttr = $this->config['group_attribute'];
210 $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
212 if ($user === null) {
216 $userGroups = $this->groupFilter($user);
217 $userGroups = $this->getGroupsRecursive($userGroups, []);
222 * Get the parent groups of an array of groups
223 * @param array $groupsArray
224 * @param array $checked
226 * @throws LdapException
228 private function getGroupsRecursive($groupsArray, $checked)
231 foreach ($groupsArray as $groupName) {
232 if (in_array($groupName, $checked)) {
236 $groupsToAdd = $this->getGroupGroups($groupName);
237 $groups_to_add = array_merge($groups_to_add, $groupsToAdd);
238 $checked[] = $groupName;
240 $groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
242 if (!empty($groups_to_add)) {
243 return $this->getGroupsRecursive($groupsArray, $checked);
250 * Get the parent groups of a single group
251 * @param string $groupName
253 * @throws LdapException
255 private function getGroupGroups($groupName)
257 $ldapConnection = $this->getConnection();
258 $this->bindSystemUser($ldapConnection);
260 $followReferrals = $this->config['follow_referrals'] ? 1 : 0;
261 $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
263 $baseDn = $this->config['base_dn'];
264 $groupsAttr = strtolower($this->config['group_attribute']);
266 $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, 'CN='.$groupName, [$groupsAttr]);
267 if ($groups['count'] === 0) {
271 $groupGroups = $this->groupFilter($groups[0]);
276 * Filter out LDAP CN and DN language in a ldap search return
277 * Gets the base CN (common name) of the string
278 * @param string $ldapSearchReturn
281 protected function groupFilter($ldapSearchReturn)
283 $groupsAttr = strtolower($this->config['group_attribute']);
286 if (isset($ldapSearchReturn[$groupsAttr]['count'])) {
287 $count = (int) $ldapSearchReturn[$groupsAttr]['count'];
289 for ($i=0; $i<$count; $i++) {
290 $dnComponents = ldap_explode_dn($ldapSearchReturn[$groupsAttr][$i], 1);
291 if (!in_array($dnComponents[0], $ldapGroups)) {
292 $ldapGroups[] = $dnComponents[0];
299 * Sync the LDAP groups to the user roles for the current user
300 * @param \BookStack\User $user
301 * @throws LdapException
302 * @throws \BookStack\Exceptions\NotFoundException
304 public function syncGroups(User $user)
306 $userLdapGroups = $this->getUserGroups($user->external_auth_id);
307 $userLdapGroups = $this->groupNameFilter($userLdapGroups);
309 // Get the ids for the roles from the names
310 $ldapGroupsAsRoles = Role::query()->whereIn('name', $userLdapGroups)->pluck('id');
313 if ($this->config['remove_from_groups']) {
314 $user->roles()->sync($ldapGroupsAsRoles);
315 $this->userRepo->attachDefaultRole($user);
317 $user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
320 // make the user an admin?
322 if (in_array($this->config['admin'], $userLdapGroups)) {
323 $this->userRepo->attachSystemRole($user, 'admin');
328 * Filter to convert the groups from ldap to the format of the roles name on BookStack
329 * Spaces replaced with -, all lowercase letters
330 * @param array $groups
333 private function groupNameFilter(array $groups)
336 foreach ($groups as $groupName) {
337 $return[] = str_replace(' ', '-', strtolower($groupName));