]> BookStack Code Mirror - bookstack/blob - tests/Auth/LdapTest.php
LDAP: Review, testing and update of LDAP TLS CA cert control
[bookstack] / tests / Auth / LdapTest.php
1 <?php
2
3 namespace Tests\Auth;
4
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;
12 use Tests\TestCase;
13
14 class LdapTest extends TestCase
15 {
16     protected MockInterface $mockLdap;
17
18     protected User $mockUser;
19     protected string $resourceId = 'resource-test';
20
21     protected function setUp(): void
22     {
23         parent::setUp();
24         if (!defined('LDAP_OPT_REFERRALS')) {
25             define('LDAP_OPT_REFERRALS', 1);
26         }
27         config()->set([
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,
41         ]);
42         $this->mockLdap = $this->mock(Ldap::class);
43         $this->mockUser = User::factory()->make();
44     }
45
46     protected function runFailedAuthLogin()
47     {
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']);
52     }
53
54     protected function mockEscapes($times = 1)
55     {
56         $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function ($val) {
57             return ldap_escape($val);
58         });
59     }
60
61     protected function mockExplodes($times = 1)
62     {
63         $this->mockLdap->shouldReceive('explodeDn')->times($times)->andReturnUsing(function ($dn, $withAttrib) {
64             return ldap_explode_dn($dn, $withAttrib);
65         });
66     }
67
68     protected function mockUserLogin(?string $email = null): TestResponse
69     {
70         return $this->post('/login', [
71             'username' => $this->mockUser->name,
72             'password' => $this->mockUser->password,
73         ] + ($email ? ['email' => $email] : []));
74     }
75
76     /**
77      * Set LDAP method mocks for things we commonly call without altering.
78      */
79     protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0)
80     {
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     }
88
89     public function test_login()
90     {
91         $this->commonLdapMocks(1, 1, 2, 4, 2);
92         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
93             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
94             ->andReturn(['count' => 1, 0 => [
95                 'uid' => [$this->mockUser->name],
96                 'cn'  => [$this->mockUser->name],
97                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
98             ]]);
99
100         $resp = $this->mockUserLogin();
101         $resp->assertRedirect('/login');
102         $resp = $this->followRedirects($resp);
103         $resp->assertSee('Please enter an email to use for this account.');
104         $resp->assertSee($this->mockUser->name);
105
106         $resp = $this->followingRedirects()->mockUserLogin($this->mockUser->email);
107         $this->withHtml($resp)->assertElementExists('#home-default');
108         $resp->assertSee($this->mockUser->name);
109         $this->assertDatabaseHas('users', [
110             'email'            => $this->mockUser->email,
111             'email_confirmed'  => false,
112             'external_auth_id' => $this->mockUser->name,
113         ]);
114     }
115
116     public function test_email_domain_restriction_active_on_new_ldap_login()
117     {
118         $this->setSettings([
119             'registration-restrict' => 'testing.com',
120         ]);
121
122         $this->commonLdapMocks(1, 1, 2, 4, 2);
123         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
124             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
125             ->andReturn(['count' => 1, 0 => [
126                 'uid' => [$this->mockUser->name],
127                 'cn'  => [$this->mockUser->name],
128                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
129             ]]);
130
131         $resp = $this->mockUserLogin();
132         $resp->assertRedirect('/login');
133         $this->followRedirects($resp)->assertSee('Please enter an email to use for this account.');
134
135         $email = '[email protected]';
136         $resp = $this->mockUserLogin($email);
137         $resp->assertRedirect('/login');
138         $this->followRedirects($resp)->assertSee('That email domain does not have access to this application');
139
140         $this->assertDatabaseMissing('users', ['email' => $email]);
141     }
142
143     public function test_login_works_when_no_uid_provided_by_ldap_server()
144     {
145         $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
146
147         $this->commonLdapMocks(1, 1, 1, 2, 1);
148         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
149             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
150             ->andReturn(['count' => 1, 0 => [
151                 'cn'   => [$this->mockUser->name],
152                 'dn'   => $ldapDn,
153                 'mail' => [$this->mockUser->email],
154             ]]);
155
156         $resp = $this->mockUserLogin();
157         $resp->assertRedirect('/');
158         $this->followRedirects($resp)->assertSee($this->mockUser->name);
159         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
160     }
161
162     public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly()
163     {
164         config()->set(['services.ldap.id_attribute' => 'my_custom_id']);
165
166         $this->commonLdapMocks(1, 1, 1, 2, 1);
167         $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
168         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
169             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
170             ->andReturn(['count' => 1, 0 => [
171                 'cn'           => [$this->mockUser->name],
172                 'dn'           => $ldapDn,
173                 'my_custom_id' => ['cooluser456'],
174                 'mail'         => [$this->mockUser->email],
175             ]]);
176
177         $resp = $this->mockUserLogin();
178         $resp->assertRedirect('/');
179         $this->followRedirects($resp)->assertSee($this->mockUser->name);
180         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
181     }
182
183     public function test_initial_incorrect_credentials()
184     {
185         $this->commonLdapMocks(1, 1, 1, 0, 1);
186         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
187             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
188             ->andReturn(['count' => 1, 0 => [
189                 'uid' => [$this->mockUser->name],
190                 'cn'  => [$this->mockUser->name],
191                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
192             ]]);
193         $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false);
194
195         $resp = $this->mockUserLogin();
196         $resp->assertRedirect('/login');
197         $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
198         $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
199     }
200
201     public function test_login_not_found_username()
202     {
203         $this->commonLdapMocks(1, 1, 1, 1, 1);
204         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
205             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
206             ->andReturn(['count' => 0]);
207
208         $resp = $this->mockUserLogin();
209         $resp->assertRedirect('/login');
210         $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
211         $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
212     }
213
214     public function test_create_user_form()
215     {
216         $userForm = $this->asAdmin()->get('/settings/users/create');
217         $userForm->assertDontSee('Password');
218
219         $save = $this->post('/settings/users/create', [
220             'name'  => $this->mockUser->name,
221             'email' => $this->mockUser->email,
222         ]);
223         $save->assertSessionHasErrors(['external_auth_id' => 'The external auth id field is required.']);
224
225         $save = $this->post('/settings/users/create', [
226             'name'             => $this->mockUser->name,
227             'email'            => $this->mockUser->email,
228             'external_auth_id' => $this->mockUser->name,
229         ]);
230         $save->assertRedirect('/settings/users');
231         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
232     }
233
234     public function test_user_edit_form()
235     {
236         $editUser = $this->users->viewer();
237         $editPage = $this->asAdmin()->get("/settings/users/{$editUser->id}");
238         $editPage->assertSee('Edit User');
239         $editPage->assertDontSee('Password');
240
241         $update = $this->put("/settings/users/{$editUser->id}", [
242             'name'             => $editUser->name,
243             'email'            => $editUser->email,
244             'external_auth_id' => 'test_auth_id',
245         ]);
246         $update->assertRedirect('/settings/users');
247         $this->assertDatabaseHas('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
248     }
249
250     public function test_registration_disabled()
251     {
252         $resp = $this->followingRedirects()->get('/register');
253         $this->withHtml($resp)->assertElementContains('#content', 'Log In');
254     }
255
256     public function test_non_admins_cannot_change_auth_id()
257     {
258         $testUser = $this->users->viewer();
259         $this->actingAs($testUser)
260             ->get('/settings/users/' . $testUser->id)
261             ->assertDontSee('External Authentication');
262     }
263
264     public function test_login_maps_roles_and_retains_existing_roles()
265     {
266         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
267         $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
268         $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
269         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
270         $this->mockUser->attachRole($existingRole);
271
272         app('config')->set([
273             'services.ldap.user_to_groups'     => true,
274             'services.ldap.group_attribute'    => 'memberOf',
275             'services.ldap.remove_from_groups' => false,
276         ]);
277
278         $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
279         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
280             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
281             ->andReturn(['count' => 1, 0 => [
282                 'uid'      => [$this->mockUser->name],
283                 'cn'       => [$this->mockUser->name],
284                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
285                 'mail'     => [$this->mockUser->email],
286                 'memberof' => [
287                     'count' => 2,
288                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
289                     1       => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
290                 ],
291             ]]);
292
293         $this->mockUserLogin()->assertRedirect('/');
294
295         $user = User::where('email', $this->mockUser->email)->first();
296         $this->assertDatabaseHas('role_user', [
297             'user_id' => $user->id,
298             'role_id' => $roleToReceive->id,
299         ]);
300         $this->assertDatabaseHas('role_user', [
301             'user_id' => $user->id,
302             'role_id' => $roleToReceive2->id,
303         ]);
304         $this->assertDatabaseHas('role_user', [
305             'user_id' => $user->id,
306             'role_id' => $existingRole->id,
307         ]);
308     }
309
310     public function test_login_maps_roles_and_removes_old_roles_if_set()
311     {
312         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
313         $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
314         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
315         $this->mockUser->attachRole($existingRole);
316
317         app('config')->set([
318             'services.ldap.user_to_groups'     => true,
319             'services.ldap.group_attribute'    => 'memberOf',
320             'services.ldap.remove_from_groups' => true,
321         ]);
322
323         $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
324         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
325             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
326             ->andReturn(['count' => 1, 0 => [
327                 'uid'      => [$this->mockUser->name],
328                 'cn'       => [$this->mockUser->name],
329                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
330                 'mail'     => [$this->mockUser->email],
331                 'memberof' => [
332                     'count' => 1,
333                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
334                 ],
335             ]]);
336
337         $this->mockUserLogin()->assertRedirect('/');
338
339         $user = User::query()->where('email', $this->mockUser->email)->first();
340         $this->assertDatabaseHas('role_user', [
341             'user_id' => $user->id,
342             'role_id' => $roleToReceive->id,
343         ]);
344         $this->assertDatabaseMissing('role_user', [
345             'user_id' => $user->id,
346             'role_id' => $existingRole->id,
347         ]);
348     }
349
350     public function test_dump_user_groups_shows_group_related_details_as_json()
351     {
352         app('config')->set([
353             'services.ldap.user_to_groups'     => true,
354             'services.ldap.group_attribute'    => 'memberOf',
355             'services.ldap.remove_from_groups' => true,
356             'services.ldap.dump_user_groups'   => true,
357         ]);
358
359         $userResp = ['count' => 1, 0 => [
360             'uid'      => [$this->mockUser->name],
361             'cn'       => [$this->mockUser->name],
362             'dn'       => 'dc=test,' . config('services.ldap.base_dn'),
363             'mail'     => [$this->mockUser->email],
364         ]];
365         $this->commonLdapMocks(1, 1, 4, 5, 4, 2);
366         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
367             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
368             ->andReturn($userResp, ['count' => 1,
369                 0                           => [
370                     'dn'       => 'dc=test,' . config('services.ldap.base_dn'),
371                     'memberof' => [
372                         'count' => 1,
373                         0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
374                     ],
375                 ],
376             ], [
377                 'count' => 1,
378                 0       => [
379                     'dn'       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
380                     'memberof' => [
381                         'count' => 1,
382                         0       => 'cn=monsters,ou=groups,dc=example,dc=com',
383                     ],
384                 ],
385             ], ['count' => 0]);
386
387         $resp = $this->mockUserLogin();
388         $resp->assertJson([
389             'details_from_ldap' => [
390                 'dn'       => 'dc=test,' . config('services.ldap.base_dn'),
391                 'memberof' => [
392                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
393                     'count' => 1,
394                 ],
395             ],
396             'parsed_direct_user_groups' => [
397                 'ldaptester',
398             ],
399             'parsed_recursive_user_groups' => [
400                 'ldaptester',
401                 'monsters',
402             ],
403         ]);
404     }
405
406     public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
407     {
408         $role = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
409         $this->asAdmin()->get('/settings/roles/' . $role->id)
410             ->assertSee('ex-auth-a');
411     }
412
413     public function test_login_maps_roles_using_external_auth_ids_if_set()
414     {
415         $roleToReceive = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
416         $roleToNotReceive = Role::factory()->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
417
418         app('config')->set([
419             'services.ldap.user_to_groups'     => true,
420             'services.ldap.group_attribute'    => 'memberOf',
421             'services.ldap.remove_from_groups' => true,
422         ]);
423
424         $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
425         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
426             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
427             ->andReturn(['count' => 1, 0 => [
428                 'uid'      => [$this->mockUser->name],
429                 'cn'       => [$this->mockUser->name],
430                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
431                 'mail'     => [$this->mockUser->email],
432                 'memberof' => [
433                     'count' => 1,
434                     0       => 'cn=ex-auth-a,ou=groups,dc=example,dc=com',
435                 ],
436             ]]);
437
438         $this->mockUserLogin()->assertRedirect('/');
439
440         $user = User::query()->where('email', $this->mockUser->email)->first();
441         $this->assertDatabaseHas('role_user', [
442             'user_id' => $user->id,
443             'role_id' => $roleToReceive->id,
444         ]);
445         $this->assertDatabaseMissing('role_user', [
446             'user_id' => $user->id,
447             'role_id' => $roleToNotReceive->id,
448         ]);
449     }
450
451     public function test_login_group_mapping_does_not_conflict_with_default_role()
452     {
453         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
454         $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
455         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
456
457         setting()->put('registration-role', $roleToReceive->id);
458
459         app('config')->set([
460             'services.ldap.user_to_groups'     => true,
461             'services.ldap.group_attribute'    => 'memberOf',
462             'services.ldap.remove_from_groups' => true,
463         ]);
464
465         $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
466         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
467             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
468             ->andReturn(['count' => 1, 0 => [
469                 'uid'      => [$this->mockUser->name],
470                 'cn'       => [$this->mockUser->name],
471                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
472                 'mail'     => [$this->mockUser->email],
473                 'memberof' => [
474                     'count' => 2,
475                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
476                     1       => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
477                 ],
478             ]]);
479
480         $this->mockUserLogin()->assertRedirect('/');
481
482         $user = User::query()->where('email', $this->mockUser->email)->first();
483         $this->assertDatabaseHas('role_user', [
484             'user_id' => $user->id,
485             'role_id' => $roleToReceive->id,
486         ]);
487         $this->assertDatabaseHas('role_user', [
488             'user_id' => $user->id,
489             'role_id' => $roleToReceive2->id,
490         ]);
491     }
492
493     public function test_login_uses_specified_display_name_attribute()
494     {
495         app('config')->set([
496             'services.ldap.display_name_attribute' => 'displayName',
497         ]);
498
499         $this->commonLdapMocks(1, 1, 2, 4, 2);
500         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
501             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
502             ->andReturn(['count' => 1, 0 => [
503                 'uid'         => [$this->mockUser->name],
504                 'cn'          => [$this->mockUser->name],
505                 'dn'          => 'dc=test' . config('services.ldap.base_dn'),
506                 'displayname' => 'displayNameAttribute',
507             ]]);
508
509         $this->mockUserLogin()->assertRedirect('/login');
510         $this->get('/login')->assertSee('Please enter an email to use for this account.');
511
512         $resp = $this->mockUserLogin($this->mockUser->email);
513         $resp->assertRedirect('/');
514         $this->get('/')->assertSee('displayNameAttribute');
515         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
516     }
517
518     public function test_login_uses_default_display_name_attribute_if_specified_not_present()
519     {
520         app('config')->set([
521             'services.ldap.display_name_attribute' => 'displayName',
522         ]);
523
524         $this->commonLdapMocks(1, 1, 2, 4, 2);
525         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
526             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
527             ->andReturn(['count' => 1, 0 => [
528                 'uid' => [$this->mockUser->name],
529                 'cn'  => [$this->mockUser->name],
530                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
531             ]]);
532
533         $this->mockUserLogin()->assertRedirect('/login');
534         $this->get('/login')->assertSee('Please enter an email to use for this account.');
535
536         $resp = $this->mockUserLogin($this->mockUser->email);
537         $resp->assertRedirect('/');
538         $this->get('/')->assertSee($this->mockUser->name);
539         $this->assertDatabaseHas('users', [
540             'email'            => $this->mockUser->email,
541             'email_confirmed'  => false,
542             'external_auth_id' => $this->mockUser->name,
543             'name'             => $this->mockUser->name,
544         ]);
545     }
546
547     protected function checkLdapReceivesCorrectDetails($serverString, $expectedHostString): void
548     {
549         app('config')->set(['services.ldap.server' => $serverString]);
550
551         $this->mockLdap->shouldReceive('connect')
552             ->once()
553             ->with($expectedHostString)
554             ->andReturn(false);
555
556         $this->mockUserLogin();
557     }
558
559     public function test_ldap_receives_correct_connect_host_from_config()
560     {
561         $expectedResultByInput = [
562             'ldaps://bookstack:8080' => 'ldaps://bookstack:8080',
563             'ldap.bookstack.com:8080' => 'ldap://ldap.bookstack.com:8080',
564             'ldap.bookstack.com' => 'ldap://ldap.bookstack.com',
565             'ldaps://ldap.bookstack.com' => 'ldaps://ldap.bookstack.com',
566             'ldaps://ldap.bookstack.com ldap://a.b.com' => 'ldaps://ldap.bookstack.com ldap://a.b.com',
567         ];
568
569         foreach ($expectedResultByInput as $input => $expectedResult) {
570             $this->checkLdapReceivesCorrectDetails($input, $expectedResult);
571             $this->refreshApplication();
572             $this->setUp();
573         }
574     }
575
576     public function test_forgot_password_routes_inaccessible()
577     {
578         $resp = $this->get('/password/email');
579         $this->assertPermissionError($resp);
580
581         $resp = $this->post('/password/email');
582         $this->assertPermissionError($resp);
583
584         $resp = $this->get('/password/reset/abc123');
585         $this->assertPermissionError($resp);
586
587         $resp = $this->post('/password/reset');
588         $this->assertPermissionError($resp);
589     }
590
591     public function test_user_invite_routes_inaccessible()
592     {
593         $resp = $this->get('/register/invite/abc123');
594         $this->assertPermissionError($resp);
595
596         $resp = $this->post('/register/invite/abc123');
597         $this->assertPermissionError($resp);
598     }
599
600     public function test_user_register_routes_inaccessible()
601     {
602         $resp = $this->get('/register');
603         $this->assertPermissionError($resp);
604
605         $resp = $this->post('/register');
606         $this->assertPermissionError($resp);
607     }
608
609     public function test_dump_user_details_option_works()
610     {
611         config()->set(['services.ldap.dump_user_details' => true, 'services.ldap.thumbnail_attribute' => 'jpegphoto']);
612
613         $this->commonLdapMocks(1, 1, 1, 1, 1);
614         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
615             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
616             ->andReturn(['count' => 1, 0 => [
617                 'uid' => [$this->mockUser->name],
618                 'cn'  => [$this->mockUser->name],
619                 // Test dumping binary data for avatar responses
620                 'jpegphoto' => base64_decode('/9j/4AAQSkZJRg=='),
621                 'dn'        => 'dc=test' . config('services.ldap.base_dn'),
622             ]]);
623
624         $resp = $this->post('/login', [
625             'username' => $this->mockUser->name,
626             'password' => $this->mockUser->password,
627         ]);
628         $resp->assertJsonStructure([
629             'details_from_ldap'        => [],
630             'details_bookstack_parsed' => [],
631         ]);
632     }
633
634     public function test_start_tls_called_if_option_set()
635     {
636         config()->set(['services.ldap.start_tls' => true]);
637         $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true);
638         $this->runFailedAuthLogin();
639     }
640
641     public function test_connection_fails_if_tls_fails()
642     {
643         config()->set(['services.ldap.start_tls' => true]);
644         $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
645         $this->commonLdapMocks(1, 1, 0, 0, 0);
646         $resp = $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
647         $resp->assertStatus(500);
648     }
649
650     public function test_ldap_attributes_can_be_binary_decoded_if_marked()
651     {
652         config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
653         $ldapService = app()->make(LdapService::class);
654         $this->commonLdapMocks(1, 1, 1, 1, 1);
655         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
656             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn'])
657             ->andReturn(['count' => 1, 0 => [
658                 'uid' => [hex2bin('FFF8F7')],
659                 'cn'  => [$this->mockUser->name],
660                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
661             ]]);
662
663         $details = $ldapService->getUserDetails('test');
664         $this->assertEquals('fff8f7', $details['uid']);
665     }
666
667     public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user()
668     {
669         $this->commonLdapMocks(1, 1, 2, 4, 2);
670         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
671             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
672             ->andReturn(['count' => 1, 0 => [
673                 'uid'  => [$this->mockUser->name],
674                 'cn'   => [$this->mockUser->name],
675                 'dn'   => 'dc=test' . config('services.ldap.base_dn'),
676                 'mail' => '[email protected]',
677             ]], ['count' => 1, 0 => [
678                 'uid'  => ['Barry'],
679                 'cn'   => ['Scott'],
680                 'dn'   => 'dc=bscott' . config('services.ldap.base_dn'),
681                 'mail' => '[email protected]',
682             ]]);
683
684         // First user login
685         $this->mockUserLogin()->assertRedirect('/');
686
687         // Second user login
688         auth()->logout();
689         $resp = $this->followingRedirects()->post('/login', ['username' => 'bscott', 'password' => 'pass']);
690         $resp->assertSee('A user with the email [email protected] already exists but with different credentials');
691     }
692
693     public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen()
694     {
695         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
696         $user = User::factory()->make();
697         setting()->put('registration-confirmation', 'true');
698
699         app('config')->set([
700             'services.ldap.user_to_groups'     => true,
701             'services.ldap.group_attribute'    => 'memberOf',
702             'services.ldap.remove_from_groups' => true,
703         ]);
704
705         $this->commonLdapMocks(1, 1, 6, 8, 6, 4);
706         $this->mockLdap->shouldReceive('searchAndGetEntries')
707             ->times(6)
708             ->andReturn(['count' => 1, 0 => [
709                 'uid'      => [$user->name],
710                 'cn'       => [$user->name],
711                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
712                 'mail'     => [$user->email],
713                 'memberof' => [
714                     'count' => 1,
715                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
716                 ],
717             ]]);
718
719         $login = $this->followingRedirects()->mockUserLogin();
720         $login->assertSee('Thanks for registering!');
721         $this->assertDatabaseHas('users', [
722             'email'           => $user->email,
723             'email_confirmed' => false,
724         ]);
725
726         $user = User::query()->where('email', '=', $user->email)->first();
727         $this->assertDatabaseHas('role_user', [
728             'user_id' => $user->id,
729             'role_id' => $roleToReceive->id,
730         ]);
731
732         $this->assertNull(auth()->user());
733
734         $homePage = $this->get('/');
735         $homePage->assertRedirect('/login');
736
737         $login = $this->followingRedirects()->mockUserLogin();
738         $login->assertSee('Email Address Not Confirmed');
739     }
740
741     public function test_failed_logins_are_logged_when_message_configured()
742     {
743         $log = $this->withTestLogger();
744         config()->set(['logging.failed_login.message' => 'Failed login for %u']);
745         $this->runFailedAuthLogin();
746         $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));
747     }
748
749     public function test_thumbnail_attribute_used_as_user_avatar_if_configured()
750     {
751         config()->set(['services.ldap.thumbnail_attribute' => 'jpegPhoto']);
752
753         $this->commonLdapMocks(1, 1, 1, 2, 1);
754         $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
755         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
756             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
757             ->andReturn(['count' => 1, 0 => [
758                 'cn'        => [$this->mockUser->name],
759                 'dn'        => $ldapDn,
760                 'jpegphoto' => [base64_decode('/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8Q
761 EBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=')],
762                 'mail' => [$this->mockUser->email],
763             ]]);
764
765         $this->mockUserLogin()
766             ->assertRedirect('/');
767
768         $user = User::query()->where('email', '=', $this->mockUser->email)->first();
769         $this->assertNotNull($user->avatar);
770         $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
771     }
772
773     public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
774     {
775         $path = 'non_found_' . time();
776         config()->set(['services.ldap.tls_ca_cert' => $path]);
777
778         $this->commonLdapMocks(0, 0, 0, 0, 0);
779
780         $this->assertThrows(function () {
781             $this->withoutExceptionHandling()->mockUserLogin();
782         }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
783     }
784
785     public function test_tls_ca_cert_option_used_if_set_to_a_folder()
786     {
787         $path = $this->files->testFilePath('');
788         config()->set(['services.ldap.tls_ca_cert' => $path]);
789
790         $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
791         $this->runFailedAuthLogin();
792     }
793
794     public function test_tls_ca_cert_option_used_if_set_to_a_file()
795     {
796         $path = $this->files->testFilePath('test-file.txt');
797         config()->set(['services.ldap.tls_ca_cert' => $path]);
798
799         $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
800         $this->runFailedAuthLogin();
801     }
802 }