]> BookStack Code Mirror - bookstack/blob - tests/Auth/MfaVerificationTest.php
ba4c9b983a3feebce56e44c34cb5ca8904030d20
[bookstack] / tests / Auth / MfaVerificationTest.php
1 <?php
2
3 namespace Tests\Auth;
4
5 use BookStack\Auth\Access\LoginService;
6 use BookStack\Auth\Access\Mfa\MfaValue;
7 use BookStack\Auth\Access\Mfa\TotpService;
8 use BookStack\Auth\Role;
9 use BookStack\Auth\User;
10 use BookStack\Exceptions\StoppedAuthenticationException;
11 use Illuminate\Support\Facades\Hash;
12 use PragmaRX\Google2FA\Google2FA;
13 use Tests\TestCase;
14 use Tests\TestResponse;
15
16 class MfaVerificationTest extends TestCase
17 {
18     public function test_totp_verification()
19     {
20         [$user, $secret, $loginResp] = $this->startTotpLogin();
21         $loginResp->assertRedirect('/mfa/verify');
22
23         $resp = $this->get('/mfa/verify');
24         $resp->assertSee('Verify Access');
25         $resp->assertSee('Enter the code, generated using your mobile app, below:');
26         $this->withHtml($resp)->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"][autofocus]');
27
28         $google2fa = new Google2FA();
29         $resp = $this->post('/mfa/totp/verify', [
30             'code' => $google2fa->getCurrentOtp($secret),
31         ]);
32         $resp->assertRedirect('/');
33         $this->assertEquals($user->id, auth()->user()->id);
34     }
35
36     public function test_totp_verification_fails_on_missing_invalid_code()
37     {
38         [$user, $secret, $loginResp] = $this->startTotpLogin();
39
40         $resp = $this->get('/mfa/verify');
41         $resp = $this->post('/mfa/totp/verify', [
42             'code' => '',
43         ]);
44         $resp->assertRedirect('/mfa/verify');
45
46         $resp = $this->get('/mfa/verify');
47         $resp->assertSeeText('The code field is required.');
48         $this->assertNull(auth()->user());
49
50         $resp = $this->post('/mfa/totp/verify', [
51             'code' => '123321',
52         ]);
53         $resp->assertRedirect('/mfa/verify');
54         $resp = $this->get('/mfa/verify');
55
56         $resp->assertSeeText('The provided code is not valid or has expired.');
57         $this->assertNull(auth()->user());
58     }
59
60     public function test_backup_code_verification()
61     {
62         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
63         $loginResp->assertRedirect('/mfa/verify');
64
65         $resp = $this->get('/mfa/verify');
66         $resp->assertSee('Verify Access');
67         $resp->assertSee('Backup Code');
68         $resp->assertSee('Enter one of your remaining backup codes below:');
69         $this->withHtml($resp)->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
70
71         $resp = $this->post('/mfa/backup_codes/verify', [
72             'code' => $codes[1],
73         ]);
74
75         $resp->assertRedirect('/');
76         $this->assertEquals($user->id, auth()->user()->id);
77         // Ensure code no longer exists in available set
78         $userCodes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES);
79         $this->assertStringNotContainsString($codes[1], $userCodes);
80         $this->assertStringContainsString($codes[0], $userCodes);
81     }
82
83     public function test_backup_code_verification_fails_on_missing_or_invalid_code()
84     {
85         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
86
87         $resp = $this->get('/mfa/verify');
88         $resp = $this->post('/mfa/backup_codes/verify', [
89             'code' => '',
90         ]);
91         $resp->assertRedirect('/mfa/verify');
92
93         $resp = $this->get('/mfa/verify');
94         $resp->assertSeeText('The code field is required.');
95         $this->assertNull(auth()->user());
96
97         $resp = $this->post('/mfa/backup_codes/verify', [
98             'code' => 'ab123-ab456',
99         ]);
100         $resp->assertRedirect('/mfa/verify');
101
102         $resp = $this->get('/mfa/verify');
103         $resp->assertSeeText('The provided code is not valid or has already been used.');
104         $this->assertNull(auth()->user());
105     }
106
107     public function test_backup_code_verification_fails_on_attempted_code_reuse()
108     {
109         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
110
111         $this->post('/mfa/backup_codes/verify', [
112             'code' => $codes[0],
113         ]);
114         $this->assertNotNull(auth()->user());
115         auth()->logout();
116         session()->flush();
117
118         $this->post('/login', ['email' => $user->email, 'password' => 'password']);
119         $this->get('/mfa/verify');
120         $resp = $this->post('/mfa/backup_codes/verify', [
121             'code' => $codes[0],
122         ]);
123         $resp->assertRedirect('/mfa/verify');
124         $this->assertNull(auth()->user());
125
126         $resp = $this->get('/mfa/verify');
127         $resp->assertSeeText('The provided code is not valid or has already been used.');
128     }
129
130     public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
131     {
132         [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
133
134         $resp = $this->post('/mfa/backup_codes/verify', [
135             'code' => $codes[0],
136         ]);
137         $resp = $this->followRedirects($resp);
138         $resp->assertSeeText('You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.');
139     }
140
141     public function test_both_mfa_options_available_if_set_on_profile()
142     {
143         $user = $this->getEditor();
144         $user->password = Hash::make('password');
145         $user->save();
146
147         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
148         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
149
150         /** @var TestResponse $mfaView */
151         $mfaView = $this->followingRedirects()->post('/login', [
152             'email'    => $user->email,
153             'password' => 'password',
154         ]);
155
156         // Totp shown by default
157         $this->withHtml($mfaView)->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
158         $this->withHtml($mfaView)->assertElementContains('a[href$="/mfa/verify?method=backup_codes"]', 'Verify using a backup code');
159
160         // Ensure can view backup_codes view
161         $resp = $this->get('/mfa/verify?method=backup_codes');
162         $this->withHtml($resp)->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
163         $this->withHtml($resp)->assertElementContains('a[href$="/mfa/verify?method=totp"]', 'Verify using a mobile app');
164     }
165
166     public function test_mfa_required_with_no_methods_leads_to_setup()
167     {
168         $user = $this->getEditor();
169         $user->password = Hash::make('password');
170         $user->save();
171         /** @var Role $role */
172         $role = $user->roles->first();
173         $role->mfa_enforced = true;
174         $role->save();
175
176         $this->assertDatabaseMissing('mfa_values', [
177             'user_id' => $user->id,
178         ]);
179
180         /** @var TestResponse $resp */
181         $resp = $this->followingRedirects()->post('/login', [
182             'email'    => $user->email,
183             'password' => 'password',
184         ]);
185
186         $resp->assertSeeText('No Methods Configured');
187         $this->withHtml($resp)->assertElementContains('a[href$="/mfa/setup"]', 'Configure');
188
189         $this->get('/mfa/backup_codes/generate');
190         $resp = $this->post('/mfa/backup_codes/confirm');
191         $resp->assertRedirect('/login');
192         $this->assertDatabaseHas('mfa_values', [
193             'user_id' => $user->id,
194         ]);
195
196         $resp = $this->get('/login');
197         $resp->assertSeeText('Multi-factor method configured, Please now login again using the configured method.');
198
199         $resp = $this->followingRedirects()->post('/login', [
200             'email'    => $user->email,
201             'password' => 'password',
202         ]);
203         $resp->assertSeeText('Enter one of your remaining backup codes below:');
204     }
205
206     public function test_mfa_setup_route_access()
207     {
208         $routes = [
209             ['get', '/mfa/setup'],
210             ['get', '/mfa/totp/generate'],
211             ['post', '/mfa/totp/confirm'],
212             ['get', '/mfa/backup_codes/generate'],
213             ['post', '/mfa/backup_codes/confirm'],
214         ];
215
216         // Non-auth access
217         foreach ($routes as [$method, $path]) {
218             $resp = $this->call($method, $path);
219             $resp->assertRedirect('/login');
220         }
221
222         // Attempted login user, who has configured mfa, access
223         // Sets up user that has MFA required after attempted login.
224         $loginService = $this->app->make(LoginService::class);
225         $user = $this->getEditor();
226         /** @var Role $role */
227         $role = $user->roles->first();
228         $role->mfa_enforced = true;
229         $role->save();
230
231         try {
232             $loginService->login($user, 'testing');
233         } catch (StoppedAuthenticationException $e) {
234         }
235         $this->assertNotNull($loginService->getLastLoginAttemptUser());
236
237         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '[]');
238         foreach ($routes as [$method, $path]) {
239             $resp = $this->call($method, $path);
240             $resp->assertRedirect('/login');
241         }
242     }
243
244     public function test_login_mfa_interception_does_not_log_error()
245     {
246         $logHandler = $this->withTestLogger();
247
248         [$user, $secret, $loginResp] = $this->startTotpLogin();
249
250         $loginResp->assertRedirect('/mfa/verify');
251         $this->assertFalse($logHandler->hasErrorRecords());
252     }
253
254     /**
255      * @return array<User, string, TestResponse>
256      */
257     protected function startTotpLogin(): array
258     {
259         $secret = $this->app->make(TotpService::class)->generateSecret();
260         $user = $this->getEditor();
261         $user->password = Hash::make('password');
262         $user->save();
263         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
264         $loginResp = $this->post('/login', [
265             'email'    => $user->email,
266             'password' => 'password',
267         ]);
268
269         return [$user, $secret, $loginResp];
270     }
271
272     /**
273      * @return array<User, string, TestResponse>
274      */
275     protected function startBackupCodeLogin($codes = ['kzzu6-1pgll', 'bzxnf-plygd', 'bwdsp-ysl51', '1vo93-ioy7n', 'lf7nw-wdyka', 'xmtrd-oplac']): array
276     {
277         $user = $this->getEditor();
278         $user->password = Hash::make('password');
279         $user->save();
280         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
281         $loginResp = $this->post('/login', [
282             'email'    => $user->email,
283             'password' => 'password',
284         ]);
285
286         return [$user, $codes, $loginResp];
287     }
288 }