1 <?php namespace Tests\Auth;
3 use BookStack\Auth\Access\LdapService;
4 use BookStack\Auth\Role;
5 use BookStack\Auth\Access\Ldap;
6 use BookStack\Auth\User;
7 use Mockery\MockInterface;
8 use Tests\BrowserKitTest;
10 class LdapTest extends BrowserKitTest
19 protected $resourceId = 'resource-test';
21 public function setUp(): void
24 if (!defined('LDAP_OPT_REFERRALS')) define('LDAP_OPT_REFERRALS', 1);
26 'auth.method' => 'ldap',
27 'auth.defaults.guard' => 'ldap',
28 'services.ldap.base_dn' => 'dc=ldap,dc=local',
29 'services.ldap.email_attribute' => 'mail',
30 'services.ldap.display_name_attribute' => 'cn',
31 'services.ldap.id_attribute' => 'uid',
32 'services.ldap.user_to_groups' => false,
33 'services.ldap.version' => '3',
34 'services.ldap.user_filter' => '(&(uid=${user}))',
35 'services.ldap.follow_referrals' => false,
36 'services.ldap.tls_insecure' => false,
38 $this->mockLdap = \Mockery::mock(Ldap::class);
39 $this->app[Ldap::class] = $this->mockLdap;
40 $this->mockUser = factory(User::class)->make();
43 protected function mockEscapes($times = 1)
45 $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function($val) {
46 return ldap_escape($val);
50 protected function mockExplodes($times = 1)
52 $this->mockLdap->shouldReceive('explodeDn')->times($times)->andReturnUsing(function($dn, $withAttrib) {
53 return ldap_explode_dn($dn, $withAttrib);
57 protected function mockUserLogin()
59 return $this->visit('/login')
61 ->type($this->mockUser->name, '#username')
62 ->type($this->mockUser->password, '#password')
67 * Set LDAP method mocks for things we commonly call without altering.
69 protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0)
71 $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId);
72 $this->mockLdap->shouldReceive('setVersion')->times($versions);
73 $this->mockLdap->shouldReceive('setOption')->times($options);
74 $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true);
75 $this->mockEscapes($escapes);
76 $this->mockExplodes($explodes);
79 public function test_login()
81 $this->commonLdapMocks(1, 1, 2, 4, 2);
82 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
83 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
84 ->andReturn(['count' => 1, 0 => [
85 'uid' => [$this->mockUser->name],
86 'cn' => [$this->mockUser->name],
87 'dn' => ['dc=test' . config('services.ldap.base_dn')]
90 $this->mockUserLogin()
91 ->seePageIs('/login')->see('Please enter an email to use for this account.');
93 $this->type($this->mockUser->email, '#email')
96 ->see($this->mockUser->name)
97 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name]);
100 public function test_email_domain_restriction_active_on_new_ldap_login()
103 'registration-restrict' => 'testing.com'
106 $this->commonLdapMocks(1, 1, 2, 4, 2);
107 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
108 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
109 ->andReturn(['count' => 1, 0 => [
110 'uid' => [$this->mockUser->name],
111 'cn' => [$this->mockUser->name],
112 'dn' => ['dc=test' . config('services.ldap.base_dn')]
115 $this->mockUserLogin()
116 ->seePageIs('/login')
117 ->see('Please enter an email to use for this account.');
121 $this->type($email, '#email')
123 ->seePageIs('/login')
124 ->see('That email domain does not have access to this application')
125 ->dontSeeInDatabase('users', ['email' => $email]);
128 public function test_login_works_when_no_uid_provided_by_ldap_server()
130 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
132 $this->commonLdapMocks(1, 1, 1, 2, 1);
133 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
134 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
135 ->andReturn(['count' => 1, 0 => [
136 'cn' => [$this->mockUser->name],
138 'mail' => [$this->mockUser->email]
141 $this->mockUserLogin()
143 ->see($this->mockUser->name)
144 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
147 public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly()
149 config()->set(['services.ldap.id_attribute' => 'my_custom_id']);
151 $this->commonLdapMocks(1, 1, 1, 2, 1);
152 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
153 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
154 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
155 ->andReturn(['count' => 1, 0 => [
156 'cn' => [$this->mockUser->name],
158 'my_custom_id' => ['cooluser456'],
159 'mail' => [$this->mockUser->email]
163 $this->mockUserLogin()
165 ->see($this->mockUser->name)
166 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
169 public function test_initial_incorrect_credentials()
171 $this->commonLdapMocks(1, 1, 1, 0, 1);
172 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
173 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
174 ->andReturn(['count' => 1, 0 => [
175 'uid' => [$this->mockUser->name],
176 'cn' => [$this->mockUser->name],
177 'dn' => ['dc=test' . config('services.ldap.base_dn')]
179 $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false);
181 $this->mockUserLogin()
182 ->seePageIs('/login')->see('These credentials do not match our records.')
183 ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]);
186 public function test_login_not_found_username()
188 $this->commonLdapMocks(1, 1, 1, 1, 1);
189 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
190 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
191 ->andReturn(['count' => 0]);
193 $this->mockUserLogin()
194 ->seePageIs('/login')->see('These credentials do not match our records.')
195 ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]);
199 public function test_create_user_form()
201 $this->asAdmin()->visit('/settings/users/create')
202 ->dontSee('Password')
203 ->type($this->mockUser->name, '#name')
204 ->type($this->mockUser->email, '#email')
206 ->see('The external auth id field is required.')
207 ->type($this->mockUser->name, '#external_auth_id')
209 ->seePageIs('/settings/users')
210 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
213 public function test_user_edit_form()
215 $editUser = $this->getNormalUser();
216 $this->asAdmin()->visit('/settings/users/' . $editUser->id)
218 ->dontSee('Password')
219 ->type('test_auth_id', '#external_auth_id')
221 ->seePageIs('/settings/users')
222 ->seeInDatabase('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
225 public function test_registration_disabled()
227 $this->visit('/register')
228 ->seePageIs('/login');
231 public function test_non_admins_cannot_change_auth_id()
233 $testUser = $this->getNormalUser();
234 $this->actingAs($testUser)->visit('/settings/users/' . $testUser->id)
235 ->dontSee('External Authentication');
238 public function test_login_maps_roles_and_retains_existing_roles()
240 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
241 $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']);
242 $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']);
243 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
244 $this->mockUser->attachRole($existingRole);
247 'services.ldap.user_to_groups' => true,
248 'services.ldap.group_attribute' => 'memberOf',
249 'services.ldap.remove_from_groups' => false,
252 $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
253 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
254 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
255 ->andReturn(['count' => 1, 0 => [
256 'uid' => [$this->mockUser->name],
257 'cn' => [$this->mockUser->name],
258 'dn' => ['dc=test' . config('services.ldap.base_dn')],
259 'mail' => [$this->mockUser->email],
262 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
263 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
267 $this->mockUserLogin()->seePageIs('/');
269 $user = User::where('email', $this->mockUser->email)->first();
270 $this->seeInDatabase('role_user', [
271 'user_id' => $user->id,
272 'role_id' => $roleToReceive->id
274 $this->seeInDatabase('role_user', [
275 'user_id' => $user->id,
276 'role_id' => $roleToReceive2->id
278 $this->seeInDatabase('role_user', [
279 'user_id' => $user->id,
280 'role_id' => $existingRole->id
284 public function test_login_maps_roles_and_removes_old_roles_if_set()
286 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
287 $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']);
288 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
289 $this->mockUser->attachRole($existingRole);
292 'services.ldap.user_to_groups' => true,
293 'services.ldap.group_attribute' => 'memberOf',
294 'services.ldap.remove_from_groups' => true,
297 $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
298 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
299 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
300 ->andReturn(['count' => 1, 0 => [
301 'uid' => [$this->mockUser->name],
302 'cn' => [$this->mockUser->name],
303 'dn' => ['dc=test' . config('services.ldap.base_dn')],
304 'mail' => [$this->mockUser->email],
307 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
311 $this->mockUserLogin()->seePageIs('/');
313 $user = User::where('email', $this->mockUser->email)->first();
314 $this->seeInDatabase('role_user', [
315 'user_id' => $user->id,
316 'role_id' => $roleToReceive->id
318 $this->dontSeeInDatabase('role_user', [
319 'user_id' => $user->id,
320 'role_id' => $existingRole->id
324 public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
326 $role = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
327 $this->asAdmin()->visit('/settings/roles/' . $role->id)
331 public function test_login_maps_roles_using_external_auth_ids_if_set()
333 $roleToReceive = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
334 $roleToNotReceive = factory(Role::class)->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
337 'services.ldap.user_to_groups' => true,
338 'services.ldap.group_attribute' => 'memberOf',
339 'services.ldap.remove_from_groups' => true,
342 $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
343 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
344 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
345 ->andReturn(['count' => 1, 0 => [
346 'uid' => [$this->mockUser->name],
347 'cn' => [$this->mockUser->name],
348 'dn' => ['dc=test' . config('services.ldap.base_dn')],
349 'mail' => [$this->mockUser->email],
352 0 => "cn=ex-auth-a,ou=groups,dc=example,dc=com",
356 $this->mockUserLogin()->seePageIs('/');
358 $user = User::where('email', $this->mockUser->email)->first();
359 $this->seeInDatabase('role_user', [
360 'user_id' => $user->id,
361 'role_id' => $roleToReceive->id
363 $this->dontSeeInDatabase('role_user', [
364 'user_id' => $user->id,
365 'role_id' => $roleToNotReceive->id
369 public function test_login_group_mapping_does_not_conflict_with_default_role()
371 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
372 $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']);
373 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
375 setting()->put('registration-role', $roleToReceive->id);
378 'services.ldap.user_to_groups' => true,
379 'services.ldap.group_attribute' => 'memberOf',
380 'services.ldap.remove_from_groups' => true,
383 $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
384 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
385 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
386 ->andReturn(['count' => 1, 0 => [
387 'uid' => [$this->mockUser->name],
388 'cn' => [$this->mockUser->name],
389 'dn' => ['dc=test' . config('services.ldap.base_dn')],
390 'mail' => [$this->mockUser->email],
393 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
394 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
398 $this->mockUserLogin()->seePageIs('/');
400 $user = User::where('email', $this->mockUser->email)->first();
401 $this->seeInDatabase('role_user', [
402 'user_id' => $user->id,
403 'role_id' => $roleToReceive->id
405 $this->seeInDatabase('role_user', [
406 'user_id' => $user->id,
407 'role_id' => $roleToReceive2->id
411 public function test_login_uses_specified_display_name_attribute()
414 'services.ldap.display_name_attribute' => 'displayName'
417 $this->commonLdapMocks(1, 1, 2, 4, 2);
418 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
419 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
420 ->andReturn(['count' => 1, 0 => [
421 'uid' => [$this->mockUser->name],
422 'cn' => [$this->mockUser->name],
423 'dn' => ['dc=test' . config('services.ldap.base_dn')],
424 'displayname' => 'displayNameAttribute'
427 $this->mockUserLogin()
428 ->seePageIs('/login')->see('Please enter an email to use for this account.');
430 $this->type($this->mockUser->email, '#email')
433 ->see('displayNameAttribute')
434 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
437 public function test_login_uses_default_display_name_attribute_if_specified_not_present()
440 'services.ldap.display_name_attribute' => 'displayName'
443 $this->commonLdapMocks(1, 1, 2, 4, 2);
444 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
445 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
446 ->andReturn(['count' => 1, 0 => [
447 'uid' => [$this->mockUser->name],
448 'cn' => [$this->mockUser->name],
449 'dn' => ['dc=test' . config('services.ldap.base_dn')]
452 $this->mockUserLogin()
453 ->seePageIs('/login')->see('Please enter an email to use for this account.');
455 $this->type($this->mockUser->email, '#email')
458 ->see($this->mockUser->name)
459 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => $this->mockUser->name]);
462 protected function checkLdapReceivesCorrectDetails($serverString, $expectedHost, $expectedPort)
465 'services.ldap.server' => $serverString
469 $this->commonLdapMocks(0, 1, 1, 2, 1);
470 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)->andReturn(['count' => 1, 0 => [
471 'uid' => [$this->mockUser->name],
472 'cn' => [$this->mockUser->name],
473 'dn' => ['dc=test' . config('services.ldap.base_dn')]
476 $this->mockLdap->shouldReceive('connect')->once()
477 ->with($expectedHost, $expectedPort)->andReturn($this->resourceId);
478 $this->mockUserLogin();
481 public function test_ldap_port_provided_on_host_if_host_is_full_uri()
483 $hostName = 'ldaps://bookstack:8080';
484 $this->checkLdapReceivesCorrectDetails($hostName, $hostName, 389);
487 public function test_ldap_port_parsed_from_server_if_host_is_not_full_uri()
489 $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com:8080', 'ldap.bookstack.com', 8080);
492 public function test_default_ldap_port_used_if_not_in_server_string_and_not_uri()
494 $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com', 'ldap.bookstack.com', 389);
497 public function test_forgot_password_routes_inaccessible()
499 $resp = $this->get('/password/email');
500 $this->assertPermissionError($resp);
502 $resp = $this->post('/password/email');
503 $this->assertPermissionError($resp);
505 $resp = $this->get('/password/reset/abc123');
506 $this->assertPermissionError($resp);
508 $resp = $this->post('/password/reset');
509 $this->assertPermissionError($resp);
512 public function test_user_invite_routes_inaccessible()
514 $resp = $this->get('/register/invite/abc123');
515 $this->assertPermissionError($resp);
517 $resp = $this->post('/register/invite/abc123');
518 $this->assertPermissionError($resp);
521 public function test_user_register_routes_inaccessible()
523 $resp = $this->get('/register');
524 $this->assertPermissionError($resp);
526 $resp = $this->post('/register');
527 $this->assertPermissionError($resp);
530 public function test_dump_user_details_option_works()
532 config()->set(['services.ldap.dump_user_details' => true]);
534 $this->commonLdapMocks(1, 1, 1, 1, 1);
535 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
536 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
537 ->andReturn(['count' => 1, 0 => [
538 'uid' => [$this->mockUser->name],
539 'cn' => [$this->mockUser->name],
540 'dn' => ['dc=test' . config('services.ldap.base_dn')]
543 $this->post('/login', [
544 'username' => $this->mockUser->name,
545 'password' => $this->mockUser->password,
547 $this->seeJsonStructure([
548 'details_from_ldap' => [],
549 'details_bookstack_parsed' => [],
553 public function test_ldap_attributes_can_be_binary_decoded_if_marked()
555 config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
556 $ldapService = app()->make(LdapService::class);
557 $this->commonLdapMocks(1, 1, 1, 1, 1);
558 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
559 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn'])
560 ->andReturn(['count' => 1, 0 => [
561 'uid' => [hex2bin('FFF8F7')],
562 'cn' => [$this->mockUser->name],
563 'dn' => ['dc=test' . config('services.ldap.base_dn')]
566 $details = $ldapService->getUserDetails('test');
567 $this->assertEquals('fff8f7', $details['uid']);
570 public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user()
572 $this->commonLdapMocks(1, 1, 2, 4, 2);
573 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
574 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
575 ->andReturn(['count' => 1, 0 => [
576 'uid' => [$this->mockUser->name],
577 'cn' => [$this->mockUser->name],
578 'dn' => ['dc=test' . config('services.ldap.base_dn')],
580 ]], ['count' => 1, 0 => [
583 'dn' => ['dc=bscott' . config('services.ldap.base_dn')],
588 $this->mockUserLogin()->seePageIs('/');
592 $this->post('/login', ['username' => 'bscott', 'password' => 'pass'])->followRedirects();
594 $this->see('A user with the email
[email protected] already exists but with different credentials');
597 public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen()
599 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
600 $user = factory(User::class)->make();
601 setting()->put('registration-confirmation', 'true');
604 'services.ldap.user_to_groups' => true,
605 'services.ldap.group_attribute' => 'memberOf',
606 'services.ldap.remove_from_groups' => true,
609 $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
610 $this->mockLdap->shouldReceive('searchAndGetEntries')
612 ->andReturn(['count' => 1, 0 => [
613 'uid' => [$user->name],
614 'cn' => [$user->name],
615 'dn' => ['dc=test' . config('services.ldap.base_dn')],
616 'mail' => [$user->email],
619 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
623 $this->mockUserLogin()->seePageIs('/register/confirm/awaiting');
624 $this->seeInDatabase('users', [
625 'email' => $user->email,
626 'email_confirmed' => false,
629 $user = User::query()->where('email', '=', $user->email)->first();
630 $this->seeInDatabase('role_user', [
631 'user_id' => $user->id,
632 'role_id' => $roleToReceive->id
635 $homePage = $this->get('/');
636 $homePage->assertRedirectedTo('/register/confirm/awaiting');
639 public function test_failed_logins_are_logged_when_message_configured()
641 $log = $this->withTestLogger();
642 config()->set(['logging.failed_login.message' => 'Failed login for %u']);
644 $this->commonLdapMocks(1, 1, 1, 1, 1);
645 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
646 ->andReturn(['count' => 0]);
648 $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
649 $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));