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;
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_backup_code_verification()
62 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
63 $loginResp->assertRedirect('/mfa/verify');
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"]');
71 $resp = $this->post('/mfa/backup_codes/verify', [
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);
83 public function test_backup_code_verification_fails_on_missing_or_invalid_code()
85 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
87 $resp = $this->get('/mfa/verify');
88 $resp = $this->post('/mfa/backup_codes/verify', [
91 $resp->assertRedirect('/mfa/verify');
93 $resp = $this->get('/mfa/verify');
94 $resp->assertSeeText('The code field is required.');
95 $this->assertNull(auth()->user());
97 $resp = $this->post('/mfa/backup_codes/verify', [
98 'code' => 'ab123-ab456',
100 $resp->assertRedirect('/mfa/verify');
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());
107 public function test_backup_code_verification_fails_on_attempted_code_reuse()
109 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
111 $this->post('/mfa/backup_codes/verify', [
114 $this->assertNotNull(auth()->user());
118 $this->post('/login', ['email' => $user->email, 'password' => 'password']);
119 $this->get('/mfa/verify');
120 $resp = $this->post('/mfa/backup_codes/verify', [
123 $resp->assertRedirect('/mfa/verify');
124 $this->assertNull(auth()->user());
126 $resp = $this->get('/mfa/verify');
127 $resp->assertSeeText('The provided code is not valid or has already been used.');
130 public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
132 [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
134 $resp = $this->post('/mfa/backup_codes/verify', [
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.');
141 public function test_both_mfa_options_available_if_set_on_profile()
143 $user = $this->getEditor();
144 $user->password = Hash::make('password');
147 MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
148 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
150 /** @var TestResponse $mfaView */
151 $mfaView = $this->followingRedirects()->post('/login', [
152 'email' => $user->email,
153 'password' => 'password',
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');
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');
166 public function test_mfa_required_with_no_methods_leads_to_setup()
168 $user = $this->getEditor();
169 $user->password = Hash::make('password');
171 /** @var Role $role */
172 $role = $user->roles->first();
173 $role->mfa_enforced = true;
176 $this->assertDatabaseMissing('mfa_values', [
177 'user_id' => $user->id,
180 /** @var TestResponse $resp */
181 $resp = $this->followingRedirects()->post('/login', [
182 'email' => $user->email,
183 'password' => 'password',
186 $resp->assertSeeText('No Methods Configured');
187 $this->withHtml($resp)->assertElementContains('a[href$="/mfa/setup"]', 'Configure');
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,
196 $resp = $this->get('/login');
197 $resp->assertSeeText('Multi-factor method configured, Please now login again using the configured method.');
199 $resp = $this->followingRedirects()->post('/login', [
200 'email' => $user->email,
201 'password' => 'password',
203 $resp->assertSeeText('Enter one of your remaining backup codes below:');
206 public function test_mfa_setup_route_access()
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'],
217 foreach ($routes as [$method, $path]) {
218 $resp = $this->call($method, $path);
219 $resp->assertRedirect('/login');
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;
232 $loginService->login($user, 'testing');
233 } catch (StoppedAuthenticationException $e) {
235 $this->assertNotNull($loginService->getLastLoginAttemptUser());
237 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '[]');
238 foreach ($routes as [$method, $path]) {
239 $resp = $this->call($method, $path);
240 $resp->assertRedirect('/login');
244 public function test_login_mfa_interception_does_not_log_error()
246 $logHandler = $this->withTestLogger();
248 [$user, $secret, $loginResp] = $this->startTotpLogin();
250 $loginResp->assertRedirect('/mfa/verify');
251 $this->assertFalse($logHandler->hasErrorRecords());
255 * @return array<User, string, TestResponse>
257 protected function startTotpLogin(): array
259 $secret = $this->app->make(TotpService::class)->generateSecret();
260 $user = $this->getEditor();
261 $user->password = Hash::make('password');
263 MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
264 $loginResp = $this->post('/login', [
265 'email' => $user->email,
266 'password' => 'password',
269 return [$user, $secret, $loginResp];
273 * @return array<User, string, TestResponse>
275 protected function startBackupCodeLogin($codes = ['kzzu6-1pgll', 'bzxnf-plygd', 'bwdsp-ysl51', '1vo93-ioy7n', 'lf7nw-wdyka', 'xmtrd-oplac']): array
277 $user = $this->getEditor();
278 $user->password = Hash::make('password');
280 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
281 $loginResp = $this->post('/login', [
282 'email' => $user->email,
283 'password' => 'password',
286 return [$user, $codes, $loginResp];