]> BookStack Code Mirror - bookstack/blob - tests/Auth/MfaVerificationTest.php
Worked on MFA setup required flow
[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         $resp->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
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         $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         $mfaView->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
158         $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         $resp->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
163         $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         $resp->assertElementContains('a[href$="/mfa/setup"]', 'Configure');
188
189         $this->get('/mfa/backup_codes/generate');
190         $this->followingRedirects()->post('/mfa/backup_codes/confirm');
191         $this->assertDatabaseHas('mfa_values', [
192             'user_id' => $user->id,
193         ]);
194
195         $resp = $this->followingRedirects()->post('/login', [
196             'email' => $user->email,
197             'password' => 'password',
198         ]);
199         $resp->assertSeeText('Enter one of your remaining backup codes below:');
200     }
201
202     public function test_mfa_setup_route_access()
203     {
204         $routes = [
205             ['get', '/mfa/setup'],
206             ['get', '/mfa/totp/generate'],
207             ['post', '/mfa/totp/confirm'],
208             ['get', '/mfa/backup_codes/generate'],
209             ['post', '/mfa/backup_codes/confirm'],
210         ];
211
212         // Non-auth access
213         foreach ($routes as [$method, $path]) {
214             $resp = $this->call($method, $path);
215             $resp->assertRedirect('/login');
216         }
217
218         // Attempted login user, who has configured mfa, access
219         // Sets up user that has MFA required after attempted login.
220         $loginService = $this->app->make(LoginService::class);
221         $user = $this->getEditor();
222         /** @var Role $role */
223         $role = $user->roles->first();
224         $role->mfa_enforced = true;
225         $role->save();
226         try {
227             $loginService->login($user, 'testing');
228         } catch (StoppedAuthenticationException $e) {
229         }
230         $this->assertNotNull($loginService->getLastLoginAttemptUser());
231
232         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '[]');
233         foreach ($routes as [$method, $path]) {
234             $resp = $this->call($method, $path);
235             $resp->assertRedirect('/login');
236         }
237
238     }
239
240     /**
241      * @return Array<User, string, TestResponse>
242      */
243     protected function startTotpLogin(): array
244     {
245         $secret = $this->app->make(TotpService::class)->generateSecret();
246         $user = $this->getEditor();
247         $user->password = Hash::make('password');
248         $user->save();
249         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
250         $loginResp = $this->post('/login', [
251             'email' => $user->email,
252             'password' => 'password',
253         ]);
254
255         return [$user, $secret, $loginResp];
256     }
257
258     /**
259      * @return Array<User, string, TestResponse>
260      */
261     protected function startBackupCodeLogin($codes = ['kzzu6-1pgll','bzxnf-plygd','bwdsp-ysl51','1vo93-ioy7n','lf7nw-wdyka','xmtrd-oplac']): array
262     {
263         $user = $this->getEditor();
264         $user->password = Hash::make('password');
265         $user->save();
266         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
267         $loginResp = $this->post('/login', [
268             'email' => $user->email,
269             'password' => 'password',
270         ]);
271
272         return [$user, $codes, $loginResp];
273     }
274
275 }