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;
14 use Tests\TestResponse;
16 class MfaVerificationTest extends TestCase
18 public function test_totp_verification()
20 [$user, $secret, $loginResp] = $this->startTotpLogin();
21 $loginResp->assertRedirect('/mfa/verify');
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]');
28 $google2fa = new Google2FA();
29 $resp = $this->post('/mfa/totp/verify', [
30 'code' => $google2fa->getCurrentOtp($secret),
32 $resp->assertRedirect('/');
33 $this->assertEquals($user->id, auth()->user()->id);
36 public function test_totp_verification_fails_on_missing_invalid_code()
38 [$user, $secret, $loginResp] = $this->startTotpLogin();
40 $resp = $this->get('/mfa/verify');
41 $resp = $this->post('/mfa/totp/verify', [
44 $resp->assertRedirect('/mfa/verify');
46 $resp = $this->get('/mfa/verify');
47 $resp->assertSeeText('The code field is required.');
48 $this->assertNull(auth()->user());
50 $resp = $this->post('/mfa/totp/verify', [
53 $resp->assertRedirect('/mfa/verify');
54 $resp = $this->get('/mfa/verify');
56 $resp->assertSeeText('The provided code is not valid or has expired.');
57 $this->assertNull(auth()->user());
60 public function test_totp_form_has_autofill_configured()
62 [$user, $secret, $loginResp] = $this->startTotpLogin();
63 $html = $this->withHtml($this->get('/mfa/verify'));
65 $html->assertElementExists('form[autocomplete="off"][action$="/verify"]');
66 $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]');
69 public function test_backup_code_verification()
71 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
72 $loginResp->assertRedirect('/mfa/verify');
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"]');
80 $resp = $this->post('/mfa/backup_codes/verify', [
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);
92 public function test_backup_code_verification_fails_on_missing_or_invalid_code()
94 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
96 $resp = $this->get('/mfa/verify');
97 $resp = $this->post('/mfa/backup_codes/verify', [
100 $resp->assertRedirect('/mfa/verify');
102 $resp = $this->get('/mfa/verify');
103 $resp->assertSeeText('The code field is required.');
104 $this->assertNull(auth()->user());
106 $resp = $this->post('/mfa/backup_codes/verify', [
107 'code' => 'ab123-ab456',
109 $resp->assertRedirect('/mfa/verify');
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());
116 public function test_backup_code_verification_fails_on_attempted_code_reuse()
118 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
120 $this->post('/mfa/backup_codes/verify', [
123 $this->assertNotNull(auth()->user());
127 $this->post('/login', ['email' => $user->email, 'password' => 'password']);
128 $this->get('/mfa/verify');
129 $resp = $this->post('/mfa/backup_codes/verify', [
132 $resp->assertRedirect('/mfa/verify');
133 $this->assertNull(auth()->user());
135 $resp = $this->get('/mfa/verify');
136 $resp->assertSeeText('The provided code is not valid or has already been used.');
139 public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
141 [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
143 $resp = $this->post('/mfa/backup_codes/verify', [
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.');
150 public function test_backup_code_form_has_autofill_configured()
152 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
153 $html = $this->withHtml($this->get('/mfa/verify'));
155 $html->assertElementExists('form[autocomplete="off"][action$="/verify"]');
156 $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]');
159 public function test_both_mfa_options_available_if_set_on_profile()
161 $user = $this->users->editor();
162 $user->password = Hash::make('password');
165 MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
166 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
168 /** @var TestResponse $mfaView */
169 $mfaView = $this->followingRedirects()->post('/login', [
170 'email' => $user->email,
171 'password' => 'password',
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');
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');
184 public function test_mfa_required_with_no_methods_leads_to_setup()
186 $user = $this->users->editor();
187 $user->password = Hash::make('password');
189 /** @var Role $role */
190 $role = $user->roles->first();
191 $role->mfa_enforced = true;
194 $this->assertDatabaseMissing('mfa_values', [
195 'user_id' => $user->id,
198 /** @var TestResponse $resp */
199 $resp = $this->followingRedirects()->post('/login', [
200 'email' => $user->email,
201 'password' => 'password',
204 $resp->assertSeeText('No Methods Configured');
205 $this->withHtml($resp)->assertElementContains('a[href$="/mfa/setup"]', 'Configure');
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,
214 $resp = $this->get('/login');
215 $resp->assertSeeText('Multi-factor method configured, Please now login again using the configured method.');
217 $resp = $this->followingRedirects()->post('/login', [
218 'email' => $user->email,
219 'password' => 'password',
221 $resp->assertSeeText('Enter one of your remaining backup codes below:');
224 public function test_mfa_setup_route_access()
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'],
235 foreach ($routes as [$method, $path]) {
236 $resp = $this->call($method, $path);
237 $resp->assertRedirect('/login');
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;
250 $loginService->login($user, 'testing');
251 } catch (StoppedAuthenticationException $e) {
253 $this->assertNotNull($loginService->getLastLoginAttemptUser());
255 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '[]');
256 foreach ($routes as [$method, $path]) {
257 $resp = $this->call($method, $path);
258 $resp->assertRedirect('/login');
262 public function test_login_mfa_interception_does_not_log_error()
264 $logHandler = $this->withTestLogger();
266 [$user, $secret, $loginResp] = $this->startTotpLogin();
268 $loginResp->assertRedirect('/mfa/verify');
269 $this->assertFalse($logHandler->hasErrorRecords());
273 * @return array<User, string, TestResponse>
275 protected function startTotpLogin(): array
277 $secret = $this->app->make(TotpService::class)->generateSecret();
278 $user = $this->users->editor();
279 $user->password = Hash::make('password');
281 MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
282 $loginResp = $this->post('/login', [
283 'email' => $user->email,
284 'password' => 'password',
287 return [$user, $secret, $loginResp];
291 * @return array<User, string, TestResponse>
293 protected function startBackupCodeLogin($codes = ['kzzu6-1pgll', 'bzxnf-plygd', 'bwdsp-ysl51', '1vo93-ioy7n', 'lf7nw-wdyka', 'xmtrd-oplac']): array
295 $user = $this->users->editor();
296 $user->password = Hash::make('password');
298 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
299 $loginResp = $this->post('/login', [
300 'email' => $user->email,
301 'password' => 'password',
304 return [$user, $codes, $loginResp];