LDAP_PASS=false
LDAP_USER_FILTER=false
LDAP_VERSION=false
+# Do you want to sync LDAP groups to BookStack roles for a user
+LDAP_USER_TO_GROUPS=false
+# What is the LDAP attribute for group memberships
+LDAP_GROUP_ATTRIBUTE="memberOf"
+# Would you like to remove users from roles on BookStack if they do not match on LDAP
+# If false, the ldap groups-roles sync will only add users to roles
+LDAP_REMOVE_FROM_GROUPS=false
# Mail settings
MAIL_DRIVER=smtp
use BookStack\Exceptions\AuthException;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\UserRepo;
+use BookStack\Services\LdapService;
use BookStack\Services\SocialAuthService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
protected $redirectAfterLogout = '/login';
protected $socialAuthService;
+ protected $ldapService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param SocialAuthService $socialAuthService
+ * @param LdapService $ldapService
* @param UserRepo $userRepo
*/
- public function __construct(SocialAuthService $socialAuthService, UserRepo $userRepo)
+ public function __construct(SocialAuthService $socialAuthService, LdapService $ldapService, UserRepo $userRepo)
{
$this->middleware('guest', ['only' => ['getLogin', 'postLogin']]);
$this->socialAuthService = $socialAuthService;
+ $this->ldapService = $ldapService;
$this->userRepo = $userRepo;
$this->redirectPath = baseUrl('/');
$this->redirectAfterLogout = baseUrl('/login');
auth()->login($user);
}
+ // Sync LDAP groups if required
+ if ($this->ldapService->shouldSyncGroups()) {
+ $this->ldapService->syncGroups($user);
+ }
+
$path = session()->pull('url.intended', '/');
$path = baseUrl($path, true);
return redirect($path);
* Redirect to the relevant social site.
* @param $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
+ * @throws \BookStack\Exceptions\SocialDriverNotConfigured
*/
public function getSocialLogin($socialDriver)
{
* @param $id
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+ * @throws PermissionsException
*/
public function updateRole($id, Request $request)
{
class Role extends Model
{
- protected $fillable = ['display_name', 'description'];
+ protected $fillable = ['display_name', 'description', 'external_auth_id'];
/**
* The roles that belong to the role.
<?php namespace BookStack\Services;
use BookStack\Exceptions\LdapException;
+use BookStack\Repos\UserRepo;
+use BookStack\Role;
+use BookStack\User;
use Illuminate\Contracts\Auth\Authenticatable;
+use Illuminate\Database\Eloquent\Builder;
/**
* Class LdapService
protected $ldap;
protected $ldapConnection;
protected $config;
+ protected $userRepo;
+ protected $enabled;
/**
* LdapService constructor.
* @param Ldap $ldap
+ * @param UserRepo $userRepo
*/
- public function __construct(Ldap $ldap)
+ public function __construct(Ldap $ldap, UserRepo $userRepo)
{
$this->ldap = $ldap;
$this->config = config('services.ldap');
+ $this->userRepo = $userRepo;
+ $this->enabled = config('auth.method') === 'ldap';
}
/**
- * Get the details of a user from LDAP using the given username.
- * User found via configurable user filter.
- * @param $userName
- * @return array|null
+ * Check if groups should be synced.
+ * @return bool
+ */
+ public function shouldSyncGroups()
+ {
+ return $this->enabled && $this->config['user_to_groups'] !== false;
+ }
+
+ /**
+ * Search for attributes for a specific user on the ldap
+ * @param string $userName
+ * @param array $attributes
+ * @return null|array
* @throws LdapException
*/
- public function getUserDetails($userName)
+ private function getUserWithAttributes($userName, $attributes)
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
// Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn'];
- $emailAttr = $this->config['email_attribute'];
+
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
- $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, ['cn', 'uid', 'dn', $emailAttr]);
+ $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
if ($users['count'] === 0) {
return null;
}
- $user = $users[0];
+ return $users[0];
+ }
+
+ /**
+ * Get the details of a user from LDAP using the given username.
+ * User found via configurable user filter.
+ * @param $userName
+ * @return array|null
+ * @throws LdapException
+ */
+ public function getUserDetails($userName)
+ {
+ $emailAttr = $this->config['email_attribute'];
+ $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr]);
+
+ if ($user === null) {
+ return null;
+ }
+
return [
'uid' => (isset($user['uid'])) ? $user['uid'][0] : $user['dn'],
'name' => $user['cn'][0],
}
return strtr($filterString, $newAttrs);
}
+
+ /**
+ * Get the groups a user is a part of on ldap
+ * @param string $userName
+ * @return array|null
+ * @throws LdapException
+ */
+ public function getUserGroups($userName)
+ {
+ $groupsAttr = $this->config['group_attribute'];
+ $user = $this->getUserWithAttributes($userName, [$groupsAttr]);
+
+ if ($user === null) {
+ return null;
+ }
+
+ $userGroups = $this->groupFilter($user);
+ $userGroups = $this->getGroupsRecursive($userGroups, []);
+ return $userGroups;
+ }
+
+ /**
+ * Get the parent groups of an array of groups
+ * @param array $groupsArray
+ * @param array $checked
+ * @return array
+ * @throws LdapException
+ */
+ private function getGroupsRecursive($groupsArray, $checked)
+ {
+ $groups_to_add = [];
+ foreach ($groupsArray as $groupName) {
+ if (in_array($groupName, $checked)) {
+ continue;
+ }
+
+ $groupsToAdd = $this->getGroupGroups($groupName);
+ $groups_to_add = array_merge($groups_to_add, $groupsToAdd);
+ $checked[] = $groupName;
+ }
+ $groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
+
+ if (!empty($groups_to_add)) {
+ return $this->getGroupsRecursive($groupsArray, $checked);
+ } else {
+ return $groupsArray;
+ }
+ }
+
+ /**
+ * Get the parent groups of a single group
+ * @param string $groupName
+ * @return array
+ * @throws LdapException
+ */
+ private function getGroupGroups($groupName)
+ {
+ $ldapConnection = $this->getConnection();
+ $this->bindSystemUser($ldapConnection);
+
+ $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']);
+
+ $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, 'CN='.$groupName, [$groupsAttr]);
+ if ($groups['count'] === 0) {
+ return [];
+ }
+
+ $groupGroups = $this->groupFilter($groups[0]);
+ return $groupGroups;
+ }
+
+ /**
+ * Filter out LDAP CN and DN language in a ldap search return
+ * Gets the base CN (common name) of the string
+ * @param string $ldapSearchReturn
+ * @return array
+ */
+ protected function groupFilter($ldapSearchReturn)
+ {
+ $groupsAttr = strtolower($this->config['group_attribute']);
+ $ldapGroups = [];
+ $count = 0;
+ if (isset($ldapSearchReturn[$groupsAttr]['count'])) {
+ $count = (int) $ldapSearchReturn[$groupsAttr]['count'];
+ }
+ for ($i=0; $i<$count; $i++) {
+ $dnComponents = ldap_explode_dn($ldapSearchReturn[$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
+ * @param \BookStack\User $user
+ * @throws LdapException
+ */
+ public function syncGroups(User $user)
+ {
+ $userLdapGroups = $this->getUserGroups($user->external_auth_id);
+
+ // Get the ids for the roles from the names
+ $ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups);
+
+ // Sync groups
+ if ($this->config['remove_from_groups']) {
+ $user->roles()->sync($ldapGroupsAsRoles);
+ $this->userRepo->attachDefaultRole($user);
+ } else {
+ $user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
+ }
+ }
+
+ /**
+ * Match an array of group names from LDAP to BookStack system roles.
+ * Formats LDAP group names to be lower-case and hyphenated.
+ * @param array $groupNames
+ * @return \Illuminate\Support\Collection
+ */
+ protected function matchLdapGroupsToSystemsRoles(array $groupNames)
+ {
+ foreach ($groupNames as $i => $groupName) {
+ $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
+ }
+
+ $roles = Role::query()->where(function(Builder $query) use ($groupNames) {
+ $query->whereIn('name', $groupNames);
+ foreach ($groupNames as $groupName) {
+ $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
+ }
+ })->get();
+
+ $matchedRoles = $roles->filter(function(Role $role) use ($groupNames) {
+ return $this->roleMatchesGroupNames($role, $groupNames);
+ });
+
+ return $matchedRoles->pluck('id');
+ }
+
+ /**
+ * Check a role against an array of group names to see if it matches.
+ * Checked against role 'external_auth_id' if set otherwise the name of the role.
+ * @param Role $role
+ * @param array $groupNames
+ * @return bool
+ */
+ protected function roleMatchesGroupNames(Role $role, array $groupNames)
+ {
+ if ($role->external_auth_id) {
+ $externalAuthIds = explode(',', strtolower($role->external_auth_id));
+ foreach ($externalAuthIds as $externalAuthId) {
+ if (in_array(trim($externalAuthId), $groupNames)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
+ return in_array($roleName, $groupNames);
+ }
+
}
'version' => env('LDAP_VERSION', false),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
- ]
+ 'user_to_groups' => env('LDAP_USER_TO_GROUPS',false),
+ 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
+ 'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false),
+ ]
];
--- /dev/null
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddRoleExternalAuthId extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('roles', function (Blueprint $table) {
+ $table->string('external_auth_id', 200)->default('');
+ $table->index('external_auth_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('roles', function (Blueprint $table) {
+ $table->dropColumn('external_auth_id');
+ });
+ }
+}
'role_details' => 'Role Details',
'role_name' => 'Role Name',
'role_desc' => 'Short Description of Role',
+ 'role_external_auth_id' => 'External Authentication IDs',
'role_system' => 'System Permissions',
'role_manage_users' => 'Manage users',
'role_manage_roles' => 'Manage roles & role permissions',
<label for="name">{{ trans('settings.role_desc') }}</label>
@include('form/text', ['name' => 'description'])
</div>
+
+ @if(config('auth.method') === 'ldap')
+ <div class="form-group">
+ <label for="name">{{ trans('settings.role_external_auth_id') }}</label>
+ @include('form/text', ['name' => 'external_auth_id'])
+ </div>
+ @endif
+
<h5>{{ trans('settings.role_system') }}</h5>
<label>@include('settings/roles/checkbox', ['permission' => 'users-manage']) {{ trans('settings.role_manage_users') }}</label>
<label>@include('settings/roles/checkbox', ['permission' => 'user-roles-manage']) {{ trans('settings.role_manage_roles') }}</label>
<?php namespace Tests;
+use BookStack\Role;
+use BookStack\Services\Ldap;
use BookStack\User;
+use Mockery\MockInterface;
class LdapTest extends BrowserKitTest
{
+ /**
+ * @var MockInterface
+ */
protected $mockLdap;
+
protected $mockUser;
protected $resourceId = 'resource-test';
{
parent::setUp();
if (!defined('LDAP_OPT_REFERRALS')) define('LDAP_OPT_REFERRALS', 1);
- app('config')->set(['auth.method' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'auth.providers.users.driver' => 'ldap']);
- $this->mockLdap = \Mockery::mock(\BookStack\Services\Ldap::class);
- $this->app['BookStack\Services\Ldap'] = $this->mockLdap;
+ app('config')->set([
+ 'auth.method' => 'ldap',
+ 'services.ldap.base_dn' => 'dc=ldap,dc=local',
+ 'services.ldap.email_attribute' => 'mail',
+ 'services.ldap.user_to_groups' => false,
+ 'auth.providers.users.driver' => 'ldap',
+ ]);
+ $this->mockLdap = \Mockery::mock(Ldap::class);
+ $this->app[Ldap::class] = $this->mockLdap;
$this->mockUser = factory(User::class)->make();
}
->dontSee('External Authentication');
}
+ public function test_login_maps_roles_and_retains_existsing_roles()
+ {
+ $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'display_name' => 'LdapTester']);
+ $roleToReceive2 = factory(Role::class)->create(['name' => 'ldaptester-second', 'display_name' => 'LdapTester Second']);
+ $existingRole = factory(Role::class)->create(['name' => 'ldaptester-existing']);
+ $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
+ $this->mockUser->attachRole($existingRole);
+
+ app('config')->set([
+ 'services.ldap.user_to_groups' => true,
+ 'services.ldap.group_attribute' => 'memberOf',
+ 'services.ldap.remove_from_groups' => false,
+ ]);
+ $this->mockLdap->shouldReceive('connect')->times(2)->andReturn($this->resourceId);
+ $this->mockLdap->shouldReceive('setVersion')->times(2);
+ $this->mockLdap->shouldReceive('setOption')->times(5);
+ $this->mockLdap->shouldReceive('searchAndGetEntries')->times(5)
+ ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
+ ->andReturn(['count' => 1, 0 => [
+ 'uid' => [$this->mockUser->name],
+ 'cn' => [$this->mockUser->name],
+ 'dn' => ['dc=test' . config('services.ldap.base_dn')],
+ 'mail' => [$this->mockUser->email],
+ 'memberof' => [
+ 'count' => 2,
+ 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
+ 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
+ ]
+ ]]);
+ $this->mockLdap->shouldReceive('bind')->times(6)->andReturn(true);
+
+ $this->visit('/login')
+ ->see('Username')
+ ->type($this->mockUser->name, '#username')
+ ->type($this->mockUser->password, '#password')
+ ->press('Log In')
+ ->seePageIs('/');
+
+ $user = User::where('email', $this->mockUser->email)->first();
+ $this->seeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $roleToReceive->id
+ ]);
+ $this->seeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $roleToReceive2->id
+ ]);
+ $this->seeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $existingRole->id
+ ]);
+ }
+
+ public function test_login_maps_roles_and_removes_old_roles_if_set()
+ {
+ $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'display_name' => 'LdapTester']);
+ $existingRole = factory(Role::class)->create(['name' => 'ldaptester-existing']);
+ $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
+ $this->mockUser->attachRole($existingRole);
+
+ app('config')->set([
+ 'services.ldap.user_to_groups' => true,
+ 'services.ldap.group_attribute' => 'memberOf',
+ 'services.ldap.remove_from_groups' => true,
+ ]);
+ $this->mockLdap->shouldReceive('connect')->times(2)->andReturn($this->resourceId);
+ $this->mockLdap->shouldReceive('setVersion')->times(2);
+ $this->mockLdap->shouldReceive('setOption')->times(4);
+ $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
+ ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
+ ->andReturn(['count' => 1, 0 => [
+ 'uid' => [$this->mockUser->name],
+ 'cn' => [$this->mockUser->name],
+ 'dn' => ['dc=test' . config('services.ldap.base_dn')],
+ 'mail' => [$this->mockUser->email],
+ 'memberof' => [
+ 'count' => 1,
+ 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
+ ]
+ ]]);
+ $this->mockLdap->shouldReceive('bind')->times(5)->andReturn(true);
+
+ $this->visit('/login')
+ ->see('Username')
+ ->type($this->mockUser->name, '#username')
+ ->type($this->mockUser->password, '#password')
+ ->press('Log In')
+ ->seePageIs('/');
+
+ $user = User::where('email', $this->mockUser->email)->first();
+ $this->seeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $roleToReceive->id
+ ]);
+ $this->dontSeeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $existingRole->id
+ ]);
+ }
+
+ public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
+ {
+ $role = factory(Role::class)->create(['name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
+ $this->asAdmin()->visit('/settings/roles/' . $role->id)
+ ->see('ex-auth-a');
+ }
+
+ public function test_login_maps_roles_using_external_auth_ids_if_set()
+ {
+ $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
+ $roleToNotReceive = factory(Role::class)->create(['name' => 'ldaptester-not-receive', 'display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
+
+ app('config')->set([
+ 'services.ldap.user_to_groups' => true,
+ 'services.ldap.group_attribute' => 'memberOf',
+ 'services.ldap.remove_from_groups' => true,
+ ]);
+ $this->mockLdap->shouldReceive('connect')->times(2)->andReturn($this->resourceId);
+ $this->mockLdap->shouldReceive('setVersion')->times(2);
+ $this->mockLdap->shouldReceive('setOption')->times(4);
+ $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
+ ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
+ ->andReturn(['count' => 1, 0 => [
+ 'uid' => [$this->mockUser->name],
+ 'cn' => [$this->mockUser->name],
+ 'dn' => ['dc=test' . config('services.ldap.base_dn')],
+ 'mail' => [$this->mockUser->email],
+ 'memberof' => [
+ 'count' => 1,
+ 0 => "cn=ex-auth-a,ou=groups,dc=example,dc=com",
+ ]
+ ]]);
+ $this->mockLdap->shouldReceive('bind')->times(5)->andReturn(true);
+
+ $this->visit('/login')
+ ->see('Username')
+ ->type($this->mockUser->name, '#username')
+ ->type($this->mockUser->password, '#password')
+ ->press('Log In')
+ ->seePageIs('/');
+
+ $user = User::where('email', $this->mockUser->email)->first();
+ $this->seeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $roleToReceive->id
+ ]);
+ $this->dontSeeInDatabase('role_user', [
+ 'user_id' => $user->id,
+ 'role_id' => $roleToNotReceive->id
+ ]);
+ }
+
}