5 use BookStack\Access\Ldap;
6 use BookStack\Access\LdapService;
7 use BookStack\Exceptions\LdapException;
8 use BookStack\Users\Models\Role;
9 use BookStack\Users\Models\User;
10 use Illuminate\Testing\TestResponse;
11 use Mockery\MockInterface;
14 class LdapTest extends TestCase
16 protected MockInterface $mockLdap;
18 protected User $mockUser;
19 protected string $resourceId = 'resource-test';
21 protected function setUp(): void
24 if (!defined('LDAP_OPT_REFERRALS')) {
25 define('LDAP_OPT_REFERRALS', 1);
28 'auth.method' => 'ldap',
29 'auth.defaults.guard' => 'ldap',
30 'services.ldap.base_dn' => 'dc=ldap,dc=local',
31 'services.ldap.email_attribute' => 'mail',
32 'services.ldap.display_name_attribute' => 'cn',
33 'services.ldap.id_attribute' => 'uid',
34 'services.ldap.user_to_groups' => false,
35 'services.ldap.version' => '3',
36 'services.ldap.user_filter' => '(&(uid={user}))',
37 'services.ldap.follow_referrals' => false,
38 'services.ldap.tls_insecure' => false,
39 'services.ldap.tls_ca_cert' => false,
40 'services.ldap.thumbnail_attribute' => null,
42 $this->mockLdap = $this->mock(Ldap::class);
43 $this->mockUser = User::factory()->make();
46 protected function runFailedAuthLogin()
48 $this->commonLdapMocks(1, 1, 1, 1, 1);
49 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
50 ->andReturn(['count' => 0]);
51 $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
54 protected function mockEscapes($times = 1)
56 $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function ($val) {
57 return ldap_escape($val);
61 protected function mockExplodes($times = 1)
63 $this->mockLdap->shouldReceive('explodeDn')->times($times)->andReturnUsing(function ($dn, $withAttrib) {
64 return ldap_explode_dn($dn, $withAttrib);
68 protected function mockUserLogin(?string $email = null): TestResponse
70 return $this->post('/login', [
71 'username' => $this->mockUser->name,
72 'password' => $this->mockUser->password,
73 ] + ($email ? ['email' => $email] : []));
77 * Set LDAP method mocks for things we commonly call without altering.
79 protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0, int $groups = 0)
81 $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId);
82 $this->mockLdap->shouldReceive('setVersion')->times($versions);
83 $this->mockLdap->shouldReceive('setOption')->times($options);
84 $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true);
85 $this->mockEscapes($escapes);
86 $this->mockExplodes($explodes);
87 $this->mockGroupLookups($groups);
90 protected function mockGroupLookups(int $times = 1): void
92 $this->mockLdap->shouldReceive('read')->times($times)->andReturn(['count' => 0]);
93 $this->mockLdap->shouldReceive('getEntries')->times($times)->andReturn(['count' => 0]);
96 public function test_login()
98 $this->commonLdapMocks(1, 1, 2, 4, 2);
99 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
100 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
101 ->andReturn(['count' => 1, 0 => [
102 'uid' => [$this->mockUser->name],
103 'cn' => [$this->mockUser->name],
104 'dn' => 'dc=test' . config('services.ldap.base_dn'),
107 $resp = $this->mockUserLogin();
108 $resp->assertRedirect('/login');
109 $resp = $this->followRedirects($resp);
110 $resp->assertSee('Please enter an email to use for this account.');
111 $resp->assertSee($this->mockUser->name);
113 $resp = $this->followingRedirects()->mockUserLogin($this->mockUser->email);
114 $this->withHtml($resp)->assertElementExists('#home-default');
115 $resp->assertSee($this->mockUser->name);
116 $this->assertDatabaseHas('users', [
117 'email' => $this->mockUser->email,
118 'email_confirmed' => false,
119 'external_auth_id' => $this->mockUser->name,
123 public function test_email_domain_restriction_active_on_new_ldap_login()
126 'registration-restrict' => 'testing.com',
129 $this->commonLdapMocks(1, 1, 2, 4, 2);
130 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
131 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
132 ->andReturn(['count' => 1, 0 => [
133 'uid' => [$this->mockUser->name],
134 'cn' => [$this->mockUser->name],
135 'dn' => 'dc=test' . config('services.ldap.base_dn'),
138 $resp = $this->mockUserLogin();
139 $resp->assertRedirect('/login');
140 $this->followRedirects($resp)->assertSee('Please enter an email to use for this account.');
142 $email = 'tester@invaliddomain.com';
143 $resp = $this->mockUserLogin($email);
144 $resp->assertRedirect('/login');
145 $this->followRedirects($resp)->assertSee('That email domain does not have access to this application');
147 $this->assertDatabaseMissing('users', ['email' => $email]);
150 public function test_login_works_when_no_uid_provided_by_ldap_server()
152 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
154 $this->commonLdapMocks(1, 1, 1, 2, 1);
155 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
156 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
157 ->andReturn(['count' => 1, 0 => [
158 'cn' => [$this->mockUser->name],
160 'mail' => [$this->mockUser->email],
163 $resp = $this->mockUserLogin();
164 $resp->assertRedirect('/');
165 $this->followRedirects($resp)->assertSee($this->mockUser->name);
166 $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
169 public function test_login_works_when_ldap_server_does_not_provide_a_cn_value()
171 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
173 $this->commonLdapMocks(1, 1, 1, 2, 1);
174 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
175 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
176 ->andReturn(['count' => 1, 0 => [
178 'mail' => [$this->mockUser->email],
181 $resp = $this->mockUserLogin();
182 $resp->assertRedirect('/');
183 $this->assertDatabaseHas('users', [
184 'name' => 'test-user',
185 'email' => $this->mockUser->email,
189 public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly()
191 config()->set(['services.ldap.id_attribute' => 'my_custom_id']);
193 $this->commonLdapMocks(1, 1, 1, 2, 1);
194 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
195 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
196 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
197 ->andReturn(['count' => 1, 0 => [
198 'cn' => [$this->mockUser->name],
200 'my_custom_id' => ['cooluser456'],
201 'mail' => [$this->mockUser->email],
204 $resp = $this->mockUserLogin();
205 $resp->assertRedirect('/');
206 $this->followRedirects($resp)->assertSee($this->mockUser->name);
207 $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
210 public function test_user_filter_default_placeholder_format()
212 config()->set('services.ldap.user_filter', '(&(uid={user}))');
213 $this->mockUser->name = 'barryldapuser';
214 $expectedFilter = '(&(uid=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
216 $this->commonLdapMocks(1, 1, 1, 1, 1);
217 $this->mockLdap->shouldReceive('searchAndGetEntries')
219 ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
220 ->andReturn(['count' => 0, 0 => []]);
222 $resp = $this->mockUserLogin();
223 $resp->assertRedirect('/login');
226 public function test_user_filter_old_placeholder_format()
228 config()->set('services.ldap.user_filter', '(&(username=${user}))');
229 $this->mockUser->name = 'barryldapuser';
230 $expectedFilter = '(&(username=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
232 $this->commonLdapMocks(1, 1, 1, 1, 1);
233 $this->mockLdap->shouldReceive('searchAndGetEntries')
235 ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
236 ->andReturn(['count' => 0, 0 => []]);
238 $resp = $this->mockUserLogin();
239 $resp->assertRedirect('/login');
242 public function test_initial_incorrect_credentials()
244 $this->commonLdapMocks(1, 1, 1, 0, 1);
245 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
246 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
247 ->andReturn(['count' => 1, 0 => [
248 'uid' => [$this->mockUser->name],
249 'cn' => [$this->mockUser->name],
250 'dn' => 'dc=test' . config('services.ldap.base_dn'),
252 $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false);
254 $resp = $this->mockUserLogin();
255 $resp->assertRedirect('/login');
256 $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
257 $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
260 public function test_login_not_found_username()
262 $this->commonLdapMocks(1, 1, 1, 1, 1);
263 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
264 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
265 ->andReturn(['count' => 0]);
267 $resp = $this->mockUserLogin();
268 $resp->assertRedirect('/login');
269 $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
270 $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
273 public function test_create_user_form()
275 $userForm = $this->asAdmin()->get('/settings/users/create');
276 $userForm->assertDontSee('Password');
278 $save = $this->post('/settings/users/create', [
279 'name' => $this->mockUser->name,
280 'email' => $this->mockUser->email,
282 $save->assertSessionHasErrors(['external_auth_id' => 'The external auth id field is required.']);
284 $save = $this->post('/settings/users/create', [
285 'name' => $this->mockUser->name,
286 'email' => $this->mockUser->email,
287 'external_auth_id' => $this->mockUser->name,
289 $save->assertRedirect('/settings/users');
290 $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
293 public function test_user_edit_form()
295 $editUser = $this->users->viewer();
296 $editPage = $this->asAdmin()->get("/settings/users/{$editUser->id}");
297 $editPage->assertSee('Edit User');
298 $editPage->assertDontSee('Password');
300 $update = $this->put("/settings/users/{$editUser->id}", [
301 'name' => $editUser->name,
302 'email' => $editUser->email,
303 'external_auth_id' => 'test_auth_id',
305 $update->assertRedirect('/settings/users');
306 $this->assertDatabaseHas('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
309 public function test_registration_disabled()
311 $resp = $this->followingRedirects()->get('/register');
312 $this->withHtml($resp)->assertElementContains('#content', 'Log In');
315 public function test_non_admins_cannot_change_auth_id()
317 $testUser = $this->users->viewer();
318 $this->actingAs($testUser)
319 ->get('/settings/users/' . $testUser->id)
320 ->assertDontSee('External Authentication');
323 public function test_login_maps_roles_and_retains_existing_roles()
325 $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
326 $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
327 $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
328 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
329 $this->mockUser->attachRole($existingRole);
332 'services.ldap.user_to_groups' => true,
333 'services.ldap.group_attribute' => 'memberOf',
334 'services.ldap.remove_from_groups' => false,
337 $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 2);
338 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
339 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
340 ->andReturn(['count' => 1, 0 => [
341 'uid' => [$this->mockUser->name],
342 'cn' => [$this->mockUser->name],
343 'dn' => 'dc=test' . config('services.ldap.base_dn'),
344 'mail' => [$this->mockUser->email],
347 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
348 1 => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
352 $this->mockUserLogin()->assertRedirect('/');
354 $user = User::where('email', $this->mockUser->email)->first();
355 $this->assertDatabaseHas('role_user', [
356 'user_id' => $user->id,
357 'role_id' => $roleToReceive->id,
359 $this->assertDatabaseHas('role_user', [
360 'user_id' => $user->id,
361 'role_id' => $roleToReceive2->id,
363 $this->assertDatabaseHas('role_user', [
364 'user_id' => $user->id,
365 'role_id' => $existingRole->id,
369 public function test_login_maps_roles_and_removes_old_roles_if_set()
371 $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
372 $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
373 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
374 $this->mockUser->attachRole($existingRole);
377 'services.ldap.user_to_groups' => true,
378 'services.ldap.group_attribute' => 'memberOf',
379 'services.ldap.remove_from_groups' => true,
382 $this->commonLdapMocks(1, 1, 3, 4, 2, 1, 1);
383 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
384 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
385 ->andReturn(['count' => 1, 0 => [
386 'uid' => [$this->mockUser->name],
387 'cn' => [$this->mockUser->name],
388 'dn' => 'dc=test' . config('services.ldap.base_dn'),
389 'mail' => [$this->mockUser->email],
392 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
396 $this->mockUserLogin()->assertRedirect('/');
398 $user = User::query()->where('email', $this->mockUser->email)->first();
399 $this->assertDatabaseHas('role_user', [
400 'user_id' => $user->id,
401 'role_id' => $roleToReceive->id,
403 $this->assertDatabaseMissing('role_user', [
404 'user_id' => $user->id,
405 'role_id' => $existingRole->id,
409 public function test_dump_user_groups_shows_group_related_details_as_json()
412 'services.ldap.user_to_groups' => true,
413 'services.ldap.group_attribute' => 'memberOf',
414 'services.ldap.remove_from_groups' => true,
415 'services.ldap.dump_user_groups' => true,
418 $userResp = ['count' => 1, 0 => [
419 'uid' => [$this->mockUser->name],
420 'cn' => [$this->mockUser->name],
421 'dn' => 'dc=test,' . config('services.ldap.base_dn'),
422 'mail' => [$this->mockUser->email],
424 $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 0);
425 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
426 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
427 ->andReturn($userResp, ['count' => 1,
429 'dn' => 'dc=test,' . config('services.ldap.base_dn'),
432 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
437 $this->mockLdap->shouldReceive('read')->times(2);
438 $this->mockLdap->shouldReceive('getEntries')->times(2)
442 'dn' => 'cn=ldaptester,ou=groups,dc=example,dc=com',
445 0 => 'cn=monsters,ou=groups,dc=example,dc=com',
450 $resp = $this->mockUserLogin();
452 'details_from_ldap' => [
453 'dn' => 'dc=test,' . config('services.ldap.base_dn'),
455 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
459 'parsed_direct_user_groups' => [
460 'cn=ldaptester,ou=groups,dc=example,dc=com',
462 'parsed_recursive_user_groups' => [
463 'cn=ldaptester,ou=groups,dc=example,dc=com',
464 'cn=monsters,ou=groups,dc=example,dc=com',
466 'parsed_resulting_group_names' => [
473 public function test_recursive_group_search_queries_via_full_dn()
476 'services.ldap.user_to_groups' => true,
477 'services.ldap.group_attribute' => 'memberOf',
480 $userResp = ['count' => 1, 0 => [
481 'uid' => [$this->mockUser->name],
482 'cn' => [$this->mockUser->name],
483 'dn' => 'dc=test,' . config('services.ldap.base_dn'),
484 'mail' => [$this->mockUser->email],
486 $groupResp = ['count' => 1,
488 'dn' => 'dc=test,' . config('services.ldap.base_dn'),
491 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
496 $this->commonLdapMocks(1, 1, 3, 4, 2, 1);
498 $escapedName = ldap_escape($this->mockUser->name);
499 $this->mockLdap->shouldReceive('searchAndGetEntries')->twice()
500 ->with($this->resourceId, config('services.ldap.base_dn'), "(&(uid={$escapedName}))", \Mockery::type('array'))
501 ->andReturn($userResp, $groupResp);
503 $this->mockLdap->shouldReceive('read')->times(1)
504 ->with($this->resourceId, 'cn=ldaptester,ou=groups,dc=example,dc=com', '(objectClass=*)', ['memberof'])
505 ->andReturn(['count' => 0]);
506 $this->mockLdap->shouldReceive('getEntries')->times(1)
507 ->with($this->resourceId, ['count' => 0])
508 ->andReturn(['count' => 0]);
510 $resp = $this->mockUserLogin();
511 $resp->assertRedirect('/');
514 public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
516 $role = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
517 $this->asAdmin()->get('/settings/roles/' . $role->id)
518 ->assertSee('ex-auth-a');
521 public function test_login_maps_roles_using_external_auth_ids_if_set()
523 $roleToReceive = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
524 $roleToNotReceive = Role::factory()->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
527 'services.ldap.user_to_groups' => true,
528 'services.ldap.group_attribute' => 'memberOf',
529 'services.ldap.remove_from_groups' => true,
532 $this->commonLdapMocks(1, 1, 3, 4, 2, 1, 1);
533 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
534 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
535 ->andReturn(['count' => 1, 0 => [
536 'uid' => [$this->mockUser->name],
537 'cn' => [$this->mockUser->name],
538 'dn' => 'dc=test' . config('services.ldap.base_dn'),
539 'mail' => [$this->mockUser->email],
542 0 => 'cn=ex-auth-a,ou=groups,dc=example,dc=com',
546 $this->mockUserLogin()->assertRedirect('/');
548 $user = User::query()->where('email', $this->mockUser->email)->first();
549 $this->assertDatabaseHas('role_user', [
550 'user_id' => $user->id,
551 'role_id' => $roleToReceive->id,
553 $this->assertDatabaseMissing('role_user', [
554 'user_id' => $user->id,
555 'role_id' => $roleToNotReceive->id,
559 public function test_login_group_mapping_does_not_conflict_with_default_role()
561 $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
562 $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
563 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
565 setting()->put('registration-role', $roleToReceive->id);
568 'services.ldap.user_to_groups' => true,
569 'services.ldap.group_attribute' => 'memberOf',
570 'services.ldap.remove_from_groups' => true,
573 $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 2);
574 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
575 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
576 ->andReturn(['count' => 1, 0 => [
577 'uid' => [$this->mockUser->name],
578 'cn' => [$this->mockUser->name],
579 'dn' => 'dc=test' . config('services.ldap.base_dn'),
580 'mail' => [$this->mockUser->email],
583 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
584 1 => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
588 $this->mockUserLogin()->assertRedirect('/');
590 $user = User::query()->where('email', $this->mockUser->email)->first();
591 $this->assertDatabaseHas('role_user', [
592 'user_id' => $user->id,
593 'role_id' => $roleToReceive->id,
595 $this->assertDatabaseHas('role_user', [
596 'user_id' => $user->id,
597 'role_id' => $roleToReceive2->id,
601 public function test_login_uses_specified_display_name_attribute()
604 'services.ldap.display_name_attribute' => 'displayName',
607 $this->commonLdapMocks(1, 1, 2, 4, 2);
608 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
609 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
610 ->andReturn(['count' => 1, 0 => [
611 'uid' => [$this->mockUser->name],
612 'cn' => [$this->mockUser->name],
613 'dn' => 'dc=test' . config('services.ldap.base_dn'),
614 'displayname' => 'displayNameAttribute',
617 $this->mockUserLogin()->assertRedirect('/login');
618 $this->get('/login')->assertSee('Please enter an email to use for this account.');
620 $resp = $this->mockUserLogin($this->mockUser->email);
621 $resp->assertRedirect('/');
622 $this->get('/')->assertSee('displayNameAttribute');
623 $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
626 public function test_login_uses_multiple_display_properties_if_defined()
629 'services.ldap.display_name_attribute' => 'firstname|middlename|noname|lastname',
632 $this->commonLdapMocks(1, 1, 1, 2, 1);
633 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
634 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
635 ->andReturn(['count' => 1, 0 => [
636 'uid' => [$this->mockUser->name],
637 'cn' => [$this->mockUser->name],
638 'dn' => 'dc=test' . config('services.ldap.base_dn'),
639 'firstname' => ['Barry'],
640 'middlename' => ['Elliott'],
641 'lastname' => ['Chuckle'],
642 'mail' => [$this->mockUser->email],
645 $this->mockUserLogin();
647 $this->assertDatabaseHas('users', [
648 'email' => $this->mockUser->email,
649 'name' => 'Barry Elliott Chuckle',
653 public function test_login_uses_default_display_name_attribute_if_specified_not_present()
656 'services.ldap.display_name_attribute' => 'displayName',
659 $this->commonLdapMocks(1, 1, 2, 4, 2);
660 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
661 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
662 ->andReturn(['count' => 1, 0 => [
663 'uid' => [$this->mockUser->name],
664 'cn' => [$this->mockUser->name],
665 'dn' => 'dc=test' . config('services.ldap.base_dn'),
668 $this->mockUserLogin()->assertRedirect('/login');
669 $this->get('/login')->assertSee('Please enter an email to use for this account.');
671 $resp = $this->mockUserLogin($this->mockUser->email);
672 $resp->assertRedirect('/');
673 $this->get('/')->assertSee($this->mockUser->name);
674 $this->assertDatabaseHas('users', [
675 'email' => $this->mockUser->email,
676 'email_confirmed' => false,
677 'external_auth_id' => $this->mockUser->name,
678 'name' => $this->mockUser->name,
682 protected function checkLdapReceivesCorrectDetails($serverString, $expectedHostString): void
684 app('config')->set(['services.ldap.server' => $serverString]);
686 $this->mockLdap->shouldReceive('connect')
688 ->with($expectedHostString)
691 $this->mockUserLogin();
694 public function test_ldap_receives_correct_connect_host_from_config()
696 $expectedResultByInput = [
697 'ldaps://bookstack:8080' => 'ldaps://bookstack:8080',
698 'ldap.bookstack.com:8080' => 'ldap://ldap.bookstack.com:8080',
699 'ldap.bookstack.com' => 'ldap://ldap.bookstack.com',
700 'ldaps://ldap.bookstack.com' => 'ldaps://ldap.bookstack.com',
701 'ldaps://ldap.bookstack.com ldap://a.b.com' => 'ldaps://ldap.bookstack.com ldap://a.b.com',
704 foreach ($expectedResultByInput as $input => $expectedResult) {
705 $this->checkLdapReceivesCorrectDetails($input, $expectedResult);
706 $this->refreshApplication();
711 public function test_forgot_password_routes_inaccessible()
713 $resp = $this->get('/password/email');
714 $this->assertPermissionError($resp);
716 $resp = $this->post('/password/email');
717 $this->assertPermissionError($resp);
719 $resp = $this->get('/password/reset/abc123');
720 $this->assertPermissionError($resp);
722 $resp = $this->post('/password/reset');
723 $this->assertPermissionError($resp);
726 public function test_user_invite_routes_inaccessible()
728 $resp = $this->get('/register/invite/abc123');
729 $this->assertPermissionError($resp);
731 $resp = $this->post('/register/invite/abc123');
732 $this->assertPermissionError($resp);
735 public function test_user_register_routes_inaccessible()
737 $resp = $this->get('/register');
738 $this->assertPermissionError($resp);
740 $resp = $this->post('/register');
741 $this->assertPermissionError($resp);
744 public function test_dump_user_details_option_works()
746 config()->set(['services.ldap.dump_user_details' => true, 'services.ldap.thumbnail_attribute' => 'jpegphoto']);
748 $this->commonLdapMocks(1, 1, 1, 1, 1);
749 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
750 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
751 ->andReturn(['count' => 1, 0 => [
752 'uid' => [$this->mockUser->name],
753 'cn' => [$this->mockUser->name],
754 // Test dumping binary data for avatar responses
755 'jpegphoto' => base64_decode('/9j/4AAQSkZJRg=='),
756 'dn' => 'dc=test' . config('services.ldap.base_dn'),
759 $resp = $this->post('/login', [
760 'username' => $this->mockUser->name,
761 'password' => $this->mockUser->password,
763 $resp->assertJsonStructure([
764 'details_from_ldap' => [],
765 'details_bookstack_parsed' => [],
769 public function test_start_tls_called_if_option_set()
771 config()->set(['services.ldap.start_tls' => true]);
772 $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true);
773 $this->runFailedAuthLogin();
776 public function test_connection_fails_if_tls_fails()
778 config()->set(['services.ldap.start_tls' => true]);
779 $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
780 $this->commonLdapMocks(1, 1, 0, 0, 0);
781 $resp = $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
782 $resp->assertStatus(500);
785 public function test_ldap_attributes_can_be_binary_decoded_if_marked()
787 config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
788 $ldapService = app()->make(LdapService::class);
789 $this->commonLdapMocks(1, 1, 1, 1, 1);
790 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
791 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn'])
792 ->andReturn(['count' => 1, 0 => [
793 'uid' => [hex2bin('FFF8F7')],
794 'cn' => [$this->mockUser->name],
795 'dn' => 'dc=test' . config('services.ldap.base_dn'),
798 $details = $ldapService->getUserDetails('test');
799 $this->assertEquals('fff8f7', $details['uid']);
802 public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user()
804 $this->commonLdapMocks(1, 1, 2, 4, 2);
805 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
806 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
807 ->andReturn(['count' => 1, 0 => [
808 'uid' => [$this->mockUser->name],
809 'cn' => [$this->mockUser->name],
810 'dn' => 'dc=test' . config('services.ldap.base_dn'),
811 'mail' => 'tester@example.com',
812 ]], ['count' => 1, 0 => [
815 'dn' => 'dc=bscott' . config('services.ldap.base_dn'),
816 'mail' => 'tester@example.com',
820 $this->mockUserLogin()->assertRedirect('/');
824 $resp = $this->followingRedirects()->post('/login', ['username' => 'bscott', 'password' => 'pass']);
825 $resp->assertSee('A user with the email tester@example.com already exists but with different credentials');
828 public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen()
830 $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
831 $user = User::factory()->make();
832 setting()->put('registration-confirmation', 'true');
835 'services.ldap.user_to_groups' => true,
836 'services.ldap.group_attribute' => 'memberOf',
837 'services.ldap.remove_from_groups' => true,
840 $this->commonLdapMocks(1, 1, 6, 8, 4, 2, 2);
841 $this->mockLdap->shouldReceive('searchAndGetEntries')
843 ->andReturn(['count' => 1, 0 => [
844 'uid' => [$user->name],
845 'cn' => [$user->name],
846 'dn' => 'dc=test' . config('services.ldap.base_dn'),
847 'mail' => [$user->email],
850 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com',
854 $login = $this->followingRedirects()->mockUserLogin();
855 $login->assertSee('Thanks for registering!');
856 $this->assertDatabaseHas('users', [
857 'email' => $user->email,
858 'email_confirmed' => false,
861 $user = User::query()->where('email', '=', $user->email)->first();
862 $this->assertDatabaseHas('role_user', [
863 'user_id' => $user->id,
864 'role_id' => $roleToReceive->id,
867 $this->assertNull(auth()->user());
869 $homePage = $this->get('/');
870 $homePage->assertRedirect('/login');
872 $login = $this->followingRedirects()->mockUserLogin();
873 $login->assertSee('Email Address Not Confirmed');
876 public function test_failed_logins_are_logged_when_message_configured()
878 $log = $this->withTestLogger();
879 config()->set(['logging.failed_login.message' => 'Failed login for %u']);
880 $this->runFailedAuthLogin();
881 $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));
884 public function test_thumbnail_attribute_used_as_user_avatar_if_configured()
886 config()->set(['services.ldap.thumbnail_attribute' => 'jpegPhoto']);
888 $this->commonLdapMocks(1, 1, 1, 2, 1);
889 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
890 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
891 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
892 ->andReturn(['count' => 1, 0 => [
893 'cn' => [$this->mockUser->name],
895 'jpegphoto' => [base64_decode('/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8Q
896 EBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=')],
897 'mail' => [$this->mockUser->email],
900 $this->mockUserLogin()
901 ->assertRedirect('/');
903 $user = User::query()->where('email', '=', $this->mockUser->email)->first();
904 $this->assertNotNull($user->avatar);
905 $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
908 public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
910 $path = 'non_found_' . time();
911 config()->set(['services.ldap.tls_ca_cert' => $path]);
913 $this->commonLdapMocks(0, 0, 0, 0, 0);
915 $this->assertThrows(function () {
916 $this->withoutExceptionHandling()->mockUserLogin();
917 }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
920 public function test_tls_ca_cert_option_used_if_set_to_a_folder()
922 $path = $this->files->testFilePath('');
923 config()->set(['services.ldap.tls_ca_cert' => $path]);
925 $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
926 $this->runFailedAuthLogin();
929 public function test_tls_ca_cert_option_used_if_set_to_a_file()
931 $path = $this->files->testFilePath('test-file.txt');
932 config()->set(['services.ldap.tls_ca_cert' => $path]);
934 $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
935 $this->runFailedAuthLogin();