]> BookStack Code Mirror - bookstack/blob - tests/Auth/LdapTest.php
Update docker-compose.yml
[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_user_filter_default_placeholder_format()
184     {
185         config()->set('services.ldap.user_filter', '(&(uid={user}))');
186         $this->mockUser->name = 'barryldapuser';
187         $expectedFilter = '(&(uid=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
188
189         $this->commonLdapMocks(1, 1, 1, 1, 1);
190         $this->mockLdap->shouldReceive('searchAndGetEntries')
191             ->once()
192             ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
193             ->andReturn(['count' => 0, 0 => []]);
194
195         $resp = $this->mockUserLogin();
196         $resp->assertRedirect('/login');
197     }
198
199     public function test_user_filter_old_placeholder_format()
200     {
201         config()->set('services.ldap.user_filter', '(&(username=${user}))');
202         $this->mockUser->name = 'barryldapuser';
203         $expectedFilter = '(&(username=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
204
205         $this->commonLdapMocks(1, 1, 1, 1, 1);
206         $this->mockLdap->shouldReceive('searchAndGetEntries')
207             ->once()
208             ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
209             ->andReturn(['count' => 0, 0 => []]);
210
211         $resp = $this->mockUserLogin();
212         $resp->assertRedirect('/login');
213     }
214
215     public function test_initial_incorrect_credentials()
216     {
217         $this->commonLdapMocks(1, 1, 1, 0, 1);
218         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
219             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
220             ->andReturn(['count' => 1, 0 => [
221                 'uid' => [$this->mockUser->name],
222                 'cn'  => [$this->mockUser->name],
223                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
224             ]]);
225         $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false);
226
227         $resp = $this->mockUserLogin();
228         $resp->assertRedirect('/login');
229         $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
230         $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
231     }
232
233     public function test_login_not_found_username()
234     {
235         $this->commonLdapMocks(1, 1, 1, 1, 1);
236         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
237             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
238             ->andReturn(['count' => 0]);
239
240         $resp = $this->mockUserLogin();
241         $resp->assertRedirect('/login');
242         $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
243         $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
244     }
245
246     public function test_create_user_form()
247     {
248         $userForm = $this->asAdmin()->get('/settings/users/create');
249         $userForm->assertDontSee('Password');
250
251         $save = $this->post('/settings/users/create', [
252             'name'  => $this->mockUser->name,
253             'email' => $this->mockUser->email,
254         ]);
255         $save->assertSessionHasErrors(['external_auth_id' => 'The external auth id field is required.']);
256
257         $save = $this->post('/settings/users/create', [
258             'name'             => $this->mockUser->name,
259             'email'            => $this->mockUser->email,
260             'external_auth_id' => $this->mockUser->name,
261         ]);
262         $save->assertRedirect('/settings/users');
263         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
264     }
265
266     public function test_user_edit_form()
267     {
268         $editUser = $this->users->viewer();
269         $editPage = $this->asAdmin()->get("/settings/users/{$editUser->id}");
270         $editPage->assertSee('Edit User');
271         $editPage->assertDontSee('Password');
272
273         $update = $this->put("/settings/users/{$editUser->id}", [
274             'name'             => $editUser->name,
275             'email'            => $editUser->email,
276             'external_auth_id' => 'test_auth_id',
277         ]);
278         $update->assertRedirect('/settings/users');
279         $this->assertDatabaseHas('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
280     }
281
282     public function test_registration_disabled()
283     {
284         $resp = $this->followingRedirects()->get('/register');
285         $this->withHtml($resp)->assertElementContains('#content', 'Log In');
286     }
287
288     public function test_non_admins_cannot_change_auth_id()
289     {
290         $testUser = $this->users->viewer();
291         $this->actingAs($testUser)
292             ->get('/settings/users/' . $testUser->id)
293             ->assertDontSee('External Authentication');
294     }
295
296     public function test_login_maps_roles_and_retains_existing_roles()
297     {
298         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
299         $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
300         $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
301         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
302         $this->mockUser->attachRole($existingRole);
303
304         app('config')->set([
305             'services.ldap.user_to_groups'     => true,
306             'services.ldap.group_attribute'    => 'memberOf',
307             'services.ldap.remove_from_groups' => false,
308         ]);
309
310         $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
311         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
312             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
313             ->andReturn(['count' => 1, 0 => [
314                 'uid'      => [$this->mockUser->name],
315                 'cn'       => [$this->mockUser->name],
316                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
317                 'mail'     => [$this->mockUser->email],
318                 'memberof' => [
319                     'count' => 2,
320                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
321                     1       => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
322                 ],
323             ]]);
324
325         $this->mockUserLogin()->assertRedirect('/');
326
327         $user = User::where('email', $this->mockUser->email)->first();
328         $this->assertDatabaseHas('role_user', [
329             'user_id' => $user->id,
330             'role_id' => $roleToReceive->id,
331         ]);
332         $this->assertDatabaseHas('role_user', [
333             'user_id' => $user->id,
334             'role_id' => $roleToReceive2->id,
335         ]);
336         $this->assertDatabaseHas('role_user', [
337             'user_id' => $user->id,
338             'role_id' => $existingRole->id,
339         ]);
340     }
341
342     public function test_login_maps_roles_and_removes_old_roles_if_set()
343     {
344         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
345         $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
346         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
347         $this->mockUser->attachRole($existingRole);
348
349         app('config')->set([
350             'services.ldap.user_to_groups'     => true,
351             'services.ldap.group_attribute'    => 'memberOf',
352             'services.ldap.remove_from_groups' => true,
353         ]);
354
355         $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
356         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
357             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
358             ->andReturn(['count' => 1, 0 => [
359                 'uid'      => [$this->mockUser->name],
360                 'cn'       => [$this->mockUser->name],
361                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
362                 'mail'     => [$this->mockUser->email],
363                 'memberof' => [
364                     'count' => 1,
365                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
366                 ],
367             ]]);
368
369         $this->mockUserLogin()->assertRedirect('/');
370
371         $user = User::query()->where('email', $this->mockUser->email)->first();
372         $this->assertDatabaseHas('role_user', [
373             'user_id' => $user->id,
374             'role_id' => $roleToReceive->id,
375         ]);
376         $this->assertDatabaseMissing('role_user', [
377             'user_id' => $user->id,
378             'role_id' => $existingRole->id,
379         ]);
380     }
381
382     public function test_dump_user_groups_shows_group_related_details_as_json()
383     {
384         app('config')->set([
385             'services.ldap.user_to_groups'     => true,
386             'services.ldap.group_attribute'    => 'memberOf',
387             'services.ldap.remove_from_groups' => true,
388             'services.ldap.dump_user_groups'   => true,
389         ]);
390
391         $userResp = ['count' => 1, 0 => [
392             'uid'      => [$this->mockUser->name],
393             'cn'       => [$this->mockUser->name],
394             'dn'       => 'dc=test,' . config('services.ldap.base_dn'),
395             'mail'     => [$this->mockUser->email],
396         ]];
397         $this->commonLdapMocks(1, 1, 4, 5, 4, 2);
398         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
399             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
400             ->andReturn($userResp, ['count' => 1,
401                 0                           => [
402                     'dn'       => 'dc=test,' . config('services.ldap.base_dn'),
403                     'memberof' => [
404                         'count' => 1,
405                         0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
406                     ],
407                 ],
408             ], [
409                 'count' => 1,
410                 0       => [
411                     'dn'       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
412                     'memberof' => [
413                         'count' => 1,
414                         0       => 'cn=monsters,ou=groups,dc=example,dc=com',
415                     ],
416                 ],
417             ], ['count' => 0]);
418
419         $resp = $this->mockUserLogin();
420         $resp->assertJson([
421             'details_from_ldap' => [
422                 'dn'       => 'dc=test,' . config('services.ldap.base_dn'),
423                 'memberof' => [
424                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
425                     'count' => 1,
426                 ],
427             ],
428             'parsed_direct_user_groups' => [
429                 'ldaptester',
430             ],
431             'parsed_recursive_user_groups' => [
432                 'ldaptester',
433                 'monsters',
434             ],
435         ]);
436     }
437
438     public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
439     {
440         $role = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
441         $this->asAdmin()->get('/settings/roles/' . $role->id)
442             ->assertSee('ex-auth-a');
443     }
444
445     public function test_login_maps_roles_using_external_auth_ids_if_set()
446     {
447         $roleToReceive = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
448         $roleToNotReceive = Role::factory()->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
449
450         app('config')->set([
451             'services.ldap.user_to_groups'     => true,
452             'services.ldap.group_attribute'    => 'memberOf',
453             'services.ldap.remove_from_groups' => true,
454         ]);
455
456         $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
457         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
458             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
459             ->andReturn(['count' => 1, 0 => [
460                 'uid'      => [$this->mockUser->name],
461                 'cn'       => [$this->mockUser->name],
462                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
463                 'mail'     => [$this->mockUser->email],
464                 'memberof' => [
465                     'count' => 1,
466                     0       => 'cn=ex-auth-a,ou=groups,dc=example,dc=com',
467                 ],
468             ]]);
469
470         $this->mockUserLogin()->assertRedirect('/');
471
472         $user = User::query()->where('email', $this->mockUser->email)->first();
473         $this->assertDatabaseHas('role_user', [
474             'user_id' => $user->id,
475             'role_id' => $roleToReceive->id,
476         ]);
477         $this->assertDatabaseMissing('role_user', [
478             'user_id' => $user->id,
479             'role_id' => $roleToNotReceive->id,
480         ]);
481     }
482
483     public function test_login_group_mapping_does_not_conflict_with_default_role()
484     {
485         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
486         $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
487         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
488
489         setting()->put('registration-role', $roleToReceive->id);
490
491         app('config')->set([
492             'services.ldap.user_to_groups'     => true,
493             'services.ldap.group_attribute'    => 'memberOf',
494             'services.ldap.remove_from_groups' => true,
495         ]);
496
497         $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
498         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
499             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
500             ->andReturn(['count' => 1, 0 => [
501                 'uid'      => [$this->mockUser->name],
502                 'cn'       => [$this->mockUser->name],
503                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
504                 'mail'     => [$this->mockUser->email],
505                 'memberof' => [
506                     'count' => 2,
507                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
508                     1       => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
509                 ],
510             ]]);
511
512         $this->mockUserLogin()->assertRedirect('/');
513
514         $user = User::query()->where('email', $this->mockUser->email)->first();
515         $this->assertDatabaseHas('role_user', [
516             'user_id' => $user->id,
517             'role_id' => $roleToReceive->id,
518         ]);
519         $this->assertDatabaseHas('role_user', [
520             'user_id' => $user->id,
521             'role_id' => $roleToReceive2->id,
522         ]);
523     }
524
525     public function test_login_uses_specified_display_name_attribute()
526     {
527         app('config')->set([
528             'services.ldap.display_name_attribute' => 'displayName',
529         ]);
530
531         $this->commonLdapMocks(1, 1, 2, 4, 2);
532         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
533             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
534             ->andReturn(['count' => 1, 0 => [
535                 'uid'         => [$this->mockUser->name],
536                 'cn'          => [$this->mockUser->name],
537                 'dn'          => 'dc=test' . config('services.ldap.base_dn'),
538                 'displayname' => 'displayNameAttribute',
539             ]]);
540
541         $this->mockUserLogin()->assertRedirect('/login');
542         $this->get('/login')->assertSee('Please enter an email to use for this account.');
543
544         $resp = $this->mockUserLogin($this->mockUser->email);
545         $resp->assertRedirect('/');
546         $this->get('/')->assertSee('displayNameAttribute');
547         $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
548     }
549
550     public function test_login_uses_default_display_name_attribute_if_specified_not_present()
551     {
552         app('config')->set([
553             'services.ldap.display_name_attribute' => 'displayName',
554         ]);
555
556         $this->commonLdapMocks(1, 1, 2, 4, 2);
557         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
558             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
559             ->andReturn(['count' => 1, 0 => [
560                 'uid' => [$this->mockUser->name],
561                 'cn'  => [$this->mockUser->name],
562                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
563             ]]);
564
565         $this->mockUserLogin()->assertRedirect('/login');
566         $this->get('/login')->assertSee('Please enter an email to use for this account.');
567
568         $resp = $this->mockUserLogin($this->mockUser->email);
569         $resp->assertRedirect('/');
570         $this->get('/')->assertSee($this->mockUser->name);
571         $this->assertDatabaseHas('users', [
572             'email'            => $this->mockUser->email,
573             'email_confirmed'  => false,
574             'external_auth_id' => $this->mockUser->name,
575             'name'             => $this->mockUser->name,
576         ]);
577     }
578
579     protected function checkLdapReceivesCorrectDetails($serverString, $expectedHostString): void
580     {
581         app('config')->set(['services.ldap.server' => $serverString]);
582
583         $this->mockLdap->shouldReceive('connect')
584             ->once()
585             ->with($expectedHostString)
586             ->andReturn(false);
587
588         $this->mockUserLogin();
589     }
590
591     public function test_ldap_receives_correct_connect_host_from_config()
592     {
593         $expectedResultByInput = [
594             'ldaps://bookstack:8080' => 'ldaps://bookstack:8080',
595             'ldap.bookstack.com:8080' => 'ldap://ldap.bookstack.com:8080',
596             'ldap.bookstack.com' => 'ldap://ldap.bookstack.com',
597             'ldaps://ldap.bookstack.com' => 'ldaps://ldap.bookstack.com',
598             'ldaps://ldap.bookstack.com ldap://a.b.com' => 'ldaps://ldap.bookstack.com ldap://a.b.com',
599         ];
600
601         foreach ($expectedResultByInput as $input => $expectedResult) {
602             $this->checkLdapReceivesCorrectDetails($input, $expectedResult);
603             $this->refreshApplication();
604             $this->setUp();
605         }
606     }
607
608     public function test_forgot_password_routes_inaccessible()
609     {
610         $resp = $this->get('/password/email');
611         $this->assertPermissionError($resp);
612
613         $resp = $this->post('/password/email');
614         $this->assertPermissionError($resp);
615
616         $resp = $this->get('/password/reset/abc123');
617         $this->assertPermissionError($resp);
618
619         $resp = $this->post('/password/reset');
620         $this->assertPermissionError($resp);
621     }
622
623     public function test_user_invite_routes_inaccessible()
624     {
625         $resp = $this->get('/register/invite/abc123');
626         $this->assertPermissionError($resp);
627
628         $resp = $this->post('/register/invite/abc123');
629         $this->assertPermissionError($resp);
630     }
631
632     public function test_user_register_routes_inaccessible()
633     {
634         $resp = $this->get('/register');
635         $this->assertPermissionError($resp);
636
637         $resp = $this->post('/register');
638         $this->assertPermissionError($resp);
639     }
640
641     public function test_dump_user_details_option_works()
642     {
643         config()->set(['services.ldap.dump_user_details' => true, 'services.ldap.thumbnail_attribute' => 'jpegphoto']);
644
645         $this->commonLdapMocks(1, 1, 1, 1, 1);
646         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
647             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
648             ->andReturn(['count' => 1, 0 => [
649                 'uid' => [$this->mockUser->name],
650                 'cn'  => [$this->mockUser->name],
651                 // Test dumping binary data for avatar responses
652                 'jpegphoto' => base64_decode('/9j/4AAQSkZJRg=='),
653                 'dn'        => 'dc=test' . config('services.ldap.base_dn'),
654             ]]);
655
656         $resp = $this->post('/login', [
657             'username' => $this->mockUser->name,
658             'password' => $this->mockUser->password,
659         ]);
660         $resp->assertJsonStructure([
661             'details_from_ldap'        => [],
662             'details_bookstack_parsed' => [],
663         ]);
664     }
665
666     public function test_start_tls_called_if_option_set()
667     {
668         config()->set(['services.ldap.start_tls' => true]);
669         $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true);
670         $this->runFailedAuthLogin();
671     }
672
673     public function test_connection_fails_if_tls_fails()
674     {
675         config()->set(['services.ldap.start_tls' => true]);
676         $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
677         $this->commonLdapMocks(1, 1, 0, 0, 0);
678         $resp = $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
679         $resp->assertStatus(500);
680     }
681
682     public function test_ldap_attributes_can_be_binary_decoded_if_marked()
683     {
684         config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
685         $ldapService = app()->make(LdapService::class);
686         $this->commonLdapMocks(1, 1, 1, 1, 1);
687         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
688             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn'])
689             ->andReturn(['count' => 1, 0 => [
690                 'uid' => [hex2bin('FFF8F7')],
691                 'cn'  => [$this->mockUser->name],
692                 'dn'  => 'dc=test' . config('services.ldap.base_dn'),
693             ]]);
694
695         $details = $ldapService->getUserDetails('test');
696         $this->assertEquals('fff8f7', $details['uid']);
697     }
698
699     public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user()
700     {
701         $this->commonLdapMocks(1, 1, 2, 4, 2);
702         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
703             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
704             ->andReturn(['count' => 1, 0 => [
705                 'uid'  => [$this->mockUser->name],
706                 'cn'   => [$this->mockUser->name],
707                 'dn'   => 'dc=test' . config('services.ldap.base_dn'),
708                 'mail' => '[email protected]',
709             ]], ['count' => 1, 0 => [
710                 'uid'  => ['Barry'],
711                 'cn'   => ['Scott'],
712                 'dn'   => 'dc=bscott' . config('services.ldap.base_dn'),
713                 'mail' => '[email protected]',
714             ]]);
715
716         // First user login
717         $this->mockUserLogin()->assertRedirect('/');
718
719         // Second user login
720         auth()->logout();
721         $resp = $this->followingRedirects()->post('/login', ['username' => 'bscott', 'password' => 'pass']);
722         $resp->assertSee('A user with the email [email protected] already exists but with different credentials');
723     }
724
725     public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen()
726     {
727         $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
728         $user = User::factory()->make();
729         setting()->put('registration-confirmation', 'true');
730
731         app('config')->set([
732             'services.ldap.user_to_groups'     => true,
733             'services.ldap.group_attribute'    => 'memberOf',
734             'services.ldap.remove_from_groups' => true,
735         ]);
736
737         $this->commonLdapMocks(1, 1, 6, 8, 6, 4);
738         $this->mockLdap->shouldReceive('searchAndGetEntries')
739             ->times(6)
740             ->andReturn(['count' => 1, 0 => [
741                 'uid'      => [$user->name],
742                 'cn'       => [$user->name],
743                 'dn'       => 'dc=test' . config('services.ldap.base_dn'),
744                 'mail'     => [$user->email],
745                 'memberof' => [
746                     'count' => 1,
747                     0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
748                 ],
749             ]]);
750
751         $login = $this->followingRedirects()->mockUserLogin();
752         $login->assertSee('Thanks for registering!');
753         $this->assertDatabaseHas('users', [
754             'email'           => $user->email,
755             'email_confirmed' => false,
756         ]);
757
758         $user = User::query()->where('email', '=', $user->email)->first();
759         $this->assertDatabaseHas('role_user', [
760             'user_id' => $user->id,
761             'role_id' => $roleToReceive->id,
762         ]);
763
764         $this->assertNull(auth()->user());
765
766         $homePage = $this->get('/');
767         $homePage->assertRedirect('/login');
768
769         $login = $this->followingRedirects()->mockUserLogin();
770         $login->assertSee('Email Address Not Confirmed');
771     }
772
773     public function test_failed_logins_are_logged_when_message_configured()
774     {
775         $log = $this->withTestLogger();
776         config()->set(['logging.failed_login.message' => 'Failed login for %u']);
777         $this->runFailedAuthLogin();
778         $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));
779     }
780
781     public function test_thumbnail_attribute_used_as_user_avatar_if_configured()
782     {
783         config()->set(['services.ldap.thumbnail_attribute' => 'jpegPhoto']);
784
785         $this->commonLdapMocks(1, 1, 1, 2, 1);
786         $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
787         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
788             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
789             ->andReturn(['count' => 1, 0 => [
790                 'cn'        => [$this->mockUser->name],
791                 'dn'        => $ldapDn,
792                 'jpegphoto' => [base64_decode('/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8Q
793 EBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=')],
794                 'mail' => [$this->mockUser->email],
795             ]]);
796
797         $this->mockUserLogin()
798             ->assertRedirect('/');
799
800         $user = User::query()->where('email', '=', $this->mockUser->email)->first();
801         $this->assertNotNull($user->avatar);
802         $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
803     }
804
805     public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
806     {
807         $path = 'non_found_' . time();
808         config()->set(['services.ldap.tls_ca_cert' => $path]);
809
810         $this->commonLdapMocks(0, 0, 0, 0, 0);
811
812         $this->assertThrows(function () {
813             $this->withoutExceptionHandling()->mockUserLogin();
814         }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
815     }
816
817     public function test_tls_ca_cert_option_used_if_set_to_a_folder()
818     {
819         $path = $this->files->testFilePath('');
820         config()->set(['services.ldap.tls_ca_cert' => $path]);
821
822         $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
823         $this->runFailedAuthLogin();
824     }
825
826     public function test_tls_ca_cert_option_used_if_set_to_a_file()
827     {
828         $path = $this->files->testFilePath('test-file.txt');
829         config()->set(['services.ldap.tls_ca_cert' => $path]);
830
831         $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
832         $this->runFailedAuthLogin();
833     }
834 }