]> BookStack Code Mirror - bookstack/blob - tests/Auth/MfaVerificationTest.php
Merge branch 'bernardo-campos/development' into development
[bookstack] / tests / Auth / MfaVerificationTest.php
1 <?php
2
3 namespace Tests\Auth;
4
5 use BookStack\Access\LoginService;
6 use BookStack\Access\Mfa\MfaValue;
7 use BookStack\Access\Mfa\TotpService;
8 use BookStack\Exceptions\StoppedAuthenticationException;
9 use BookStack\Users\Models\Role;
10 use BookStack\Users\Models\User;
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_totp_form_has_autofill_configured()
61     {
62         [$user, $secret, $loginResp] = $this->startTotpLogin();
63         $html = $this->withHtml($this->get('/mfa/verify'));
64
65         $html->assertElementExists('form[autocomplete="off"][action$="/verify"]');
66         $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]');
67     }
68
69     public function test_backup_code_verification()
70     {
71         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
72         $loginResp->assertRedirect('/mfa/verify');
73
74         $resp = $this->get('/mfa/verify');
75         $resp->assertSee('Verify Access');
76         $resp->assertSee('Backup Code');
77         $resp->assertSee('Enter one of your remaining backup codes below:');
78         $this->withHtml($resp)->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
79
80         $resp = $this->post('/mfa/backup_codes/verify', [
81             'code' => $codes[1],
82         ]);
83
84         $resp->assertRedirect('/');
85         $this->assertEquals($user->id, auth()->user()->id);
86         // Ensure code no longer exists in available set
87         $userCodes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES);
88         $this->assertStringNotContainsString($codes[1], $userCodes);
89         $this->assertStringContainsString($codes[0], $userCodes);
90     }
91
92     public function test_backup_code_verification_fails_on_missing_or_invalid_code()
93     {
94         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
95
96         $resp = $this->get('/mfa/verify');
97         $resp = $this->post('/mfa/backup_codes/verify', [
98             'code' => '',
99         ]);
100         $resp->assertRedirect('/mfa/verify');
101
102         $resp = $this->get('/mfa/verify');
103         $resp->assertSeeText('The code field is required.');
104         $this->assertNull(auth()->user());
105
106         $resp = $this->post('/mfa/backup_codes/verify', [
107             'code' => 'ab123-ab456',
108         ]);
109         $resp->assertRedirect('/mfa/verify');
110
111         $resp = $this->get('/mfa/verify');
112         $resp->assertSeeText('The provided code is not valid or has already been used.');
113         $this->assertNull(auth()->user());
114     }
115
116     public function test_backup_code_verification_fails_on_attempted_code_reuse()
117     {
118         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
119
120         $this->post('/mfa/backup_codes/verify', [
121             'code' => $codes[0],
122         ]);
123         $this->assertNotNull(auth()->user());
124         auth()->logout();
125         session()->flush();
126
127         $this->post('/login', ['email' => $user->email, 'password' => 'password']);
128         $this->get('/mfa/verify');
129         $resp = $this->post('/mfa/backup_codes/verify', [
130             'code' => $codes[0],
131         ]);
132         $resp->assertRedirect('/mfa/verify');
133         $this->assertNull(auth()->user());
134
135         $resp = $this->get('/mfa/verify');
136         $resp->assertSeeText('The provided code is not valid or has already been used.');
137     }
138
139     public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
140     {
141         [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
142
143         $resp = $this->post('/mfa/backup_codes/verify', [
144             'code' => $codes[0],
145         ]);
146         $resp = $this->followRedirects($resp);
147         $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.');
148     }
149
150     public function test_backup_code_form_has_autofill_configured()
151     {
152         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
153         $html = $this->withHtml($this->get('/mfa/verify'));
154
155         $html->assertElementExists('form[autocomplete="off"][action$="/verify"]');
156         $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]');
157     }
158
159     public function test_both_mfa_options_available_if_set_on_profile()
160     {
161         $user = $this->users->editor();
162         $user->password = Hash::make('password');
163         $user->save();
164
165         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
166         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
167
168         /** @var TestResponse $mfaView */
169         $mfaView = $this->followingRedirects()->post('/login', [
170             'email'    => $user->email,
171             'password' => 'password',
172         ]);
173
174         // Totp shown by default
175         $this->withHtml($mfaView)->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
176         $this->withHtml($mfaView)->assertElementContains('a[href$="/mfa/verify?method=backup_codes"]', 'Verify using a backup code');
177
178         // Ensure can view backup_codes view
179         $resp = $this->get('/mfa/verify?method=backup_codes');
180         $this->withHtml($resp)->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
181         $this->withHtml($resp)->assertElementContains('a[href$="/mfa/verify?method=totp"]', 'Verify using a mobile app');
182     }
183
184     public function test_mfa_required_with_no_methods_leads_to_setup()
185     {
186         $user = $this->users->editor();
187         $user->password = Hash::make('password');
188         $user->save();
189         /** @var Role $role */
190         $role = $user->roles->first();
191         $role->mfa_enforced = true;
192         $role->save();
193
194         $this->assertDatabaseMissing('mfa_values', [
195             'user_id' => $user->id,
196         ]);
197
198         /** @var TestResponse $resp */
199         $resp = $this->followingRedirects()->post('/login', [
200             'email'    => $user->email,
201             'password' => 'password',
202         ]);
203
204         $resp->assertSeeText('No Methods Configured');
205         $this->withHtml($resp)->assertElementContains('a[href$="/mfa/setup"]', 'Configure');
206
207         $this->get('/mfa/backup_codes/generate');
208         $resp = $this->post('/mfa/backup_codes/confirm');
209         $resp->assertRedirect('/login');
210         $this->assertDatabaseHas('mfa_values', [
211             'user_id' => $user->id,
212         ]);
213
214         $resp = $this->get('/login');
215         $resp->assertSeeText('Multi-factor method configured, Please now login again using the configured method.');
216
217         $resp = $this->followingRedirects()->post('/login', [
218             'email'    => $user->email,
219             'password' => 'password',
220         ]);
221         $resp->assertSeeText('Enter one of your remaining backup codes below:');
222     }
223
224     public function test_mfa_setup_route_access()
225     {
226         $routes = [
227             ['get', '/mfa/setup'],
228             ['get', '/mfa/totp/generate'],
229             ['post', '/mfa/totp/confirm'],
230             ['get', '/mfa/backup_codes/generate'],
231             ['post', '/mfa/backup_codes/confirm'],
232         ];
233
234         // Non-auth access
235         foreach ($routes as [$method, $path]) {
236             $resp = $this->call($method, $path);
237             $resp->assertRedirect('/login');
238         }
239
240         // Attempted login user, who has configured mfa, access
241         // Sets up user that has MFA required after attempted login.
242         $loginService = $this->app->make(LoginService::class);
243         $user = $this->users->editor();
244         /** @var Role $role */
245         $role = $user->roles->first();
246         $role->mfa_enforced = true;
247         $role->save();
248
249         try {
250             $loginService->login($user, 'testing');
251         } catch (StoppedAuthenticationException $e) {
252         }
253         $this->assertNotNull($loginService->getLastLoginAttemptUser());
254
255         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '[]');
256         foreach ($routes as [$method, $path]) {
257             $resp = $this->call($method, $path);
258             $resp->assertRedirect('/login');
259         }
260     }
261
262     public function test_login_mfa_interception_does_not_log_error()
263     {
264         $logHandler = $this->withTestLogger();
265
266         [$user, $secret, $loginResp] = $this->startTotpLogin();
267
268         $loginResp->assertRedirect('/mfa/verify');
269         $this->assertFalse($logHandler->hasErrorRecords());
270     }
271
272     /**
273      * @return array<User, string, TestResponse>
274      */
275     protected function startTotpLogin(): array
276     {
277         $secret = $this->app->make(TotpService::class)->generateSecret();
278         $user = $this->users->editor();
279         $user->password = Hash::make('password');
280         $user->save();
281         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
282         $loginResp = $this->post('/login', [
283             'email'    => $user->email,
284             'password' => 'password',
285         ]);
286
287         return [$user, $secret, $loginResp];
288     }
289
290     /**
291      * @return array<User, string, TestResponse>
292      */
293     protected function startBackupCodeLogin($codes = ['kzzu6-1pgll', 'bzxnf-plygd', 'bwdsp-ysl51', '1vo93-ioy7n', 'lf7nw-wdyka', 'xmtrd-oplac']): array
294     {
295         $user = $this->users->editor();
296         $user->password = Hash::make('password');
297         $user->save();
298         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
299         $loginResp = $this->post('/login', [
300             'email'    => $user->email,
301             'password' => 'password',
302         ]);
303
304         return [$user, $codes, $loginResp];
305     }
306 }