5 use BookStack\Auth\Access\Mfa\MfaValue;
6 use BookStack\Auth\Access\Mfa\TotpService;
7 use BookStack\Auth\User;
8 use Illuminate\Support\Facades\Hash;
9 use PragmaRX\Google2FA\Google2FA;
11 use Tests\TestResponse;
13 class MfaVerificationTest extends TestCase
15 public function test_totp_verification()
17 [$user, $secret, $loginResp] = $this->startTotpLogin();
18 $loginResp->assertRedirect('/mfa/verify');
20 $resp = $this->get('/mfa/verify');
21 $resp->assertSee('Verify Access');
22 $resp->assertSee('Enter the code, generated using your mobile app, below:');
23 $resp->assertElementExists('form[action$="/mfa/verify/totp"] input[name="code"]');
25 $google2fa = new Google2FA();
26 $resp = $this->post('/mfa/verify/totp', [
27 'code' => $google2fa->getCurrentOtp($secret),
29 $resp->assertRedirect('/');
30 $this->assertEquals($user->id, auth()->user()->id);
33 public function test_totp_verification_fails_on_missing_invalid_code()
35 [$user, $secret, $loginResp] = $this->startTotpLogin();
37 $resp = $this->get('/mfa/verify');
38 $resp = $this->post('/mfa/verify/totp', [
41 $resp->assertRedirect('/mfa/verify');
43 $resp = $this->get('/mfa/verify');
44 $resp->assertSeeText('The code field is required.');
45 $this->assertNull(auth()->user());
47 $resp = $this->post('/mfa/verify/totp', [
50 $resp->assertRedirect('/mfa/verify');
51 $resp = $this->get('/mfa/verify');
53 $resp->assertSeeText('The provided code is not valid or has expired.');
54 $this->assertNull(auth()->user());
57 public function test_backup_code_verification()
59 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
60 $loginResp->assertRedirect('/mfa/verify');
62 $resp = $this->get('/mfa/verify');
63 $resp->assertSee('Verify Access');
64 $resp->assertSee('Backup Code');
65 $resp->assertSee('Enter one of your remaining backup codes below:');
66 $resp->assertElementExists('form[action$="/mfa/verify/backup_codes"] input[name="code"]');
68 $resp = $this->post('/mfa/verify/backup_codes', [
72 $resp->assertRedirect('/');
73 $this->assertEquals($user->id, auth()->user()->id);
74 // Ensure code no longer exists in available set
75 $userCodes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES);
76 $this->assertStringNotContainsString($codes[1], $userCodes);
77 $this->assertStringContainsString($codes[0], $userCodes);
80 public function test_backup_code_verification_fails_on_missing_or_invalid_code()
82 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
84 $resp = $this->get('/mfa/verify');
85 $resp = $this->post('/mfa/verify/backup_codes', [
88 $resp->assertRedirect('/mfa/verify');
90 $resp = $this->get('/mfa/verify');
91 $resp->assertSeeText('The code field is required.');
92 $this->assertNull(auth()->user());
94 $resp = $this->post('/mfa/verify/backup_codes', [
95 'code' => 'ab123-ab456',
97 $resp->assertRedirect('/mfa/verify');
99 $resp = $this->get('/mfa/verify');
100 $resp->assertSeeText('The provided code is not valid or has already been used.');
101 $this->assertNull(auth()->user());
104 public function test_backup_code_verification_fails_on_attempted_code_reuse()
106 [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
108 $this->post('/mfa/verify/backup_codes', [
111 $this->assertNotNull(auth()->user());
115 $this->post('/login', ['email' => $user->email, 'password' => 'password']);
116 $this->get('/mfa/verify');
117 $resp = $this->post('/mfa/verify/backup_codes', [
120 $resp->assertRedirect('/mfa/verify');
121 $this->assertNull(auth()->user());
123 $resp = $this->get('/mfa/verify');
124 $resp->assertSeeText('The provided code is not valid or has already been used.');
127 public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
129 [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
131 $resp = $this->post('/mfa/verify/backup_codes', [
134 $resp = $this->followRedirects($resp);
135 $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.');
138 public function test_both_mfa_options_available_if_set_on_profile()
140 $user = $this->getEditor();
141 $user->password = Hash::make('password');
144 MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
145 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
147 /** @var TestResponse $mfaView */
148 $mfaView = $this->followingRedirects()->post('/login', [
149 'email' => $user->email,
150 'password' => 'password',
153 // Totp shown by default
154 $mfaView->assertElementExists('form[action$="/mfa/verify/totp"] input[name="code"]');
155 $mfaView->assertElementContains('a[href$="/mfa/verify?method=backup_codes"]', 'Verify using a backup code');
157 // Ensure can view backup_codes view
158 $resp = $this->get('/mfa/verify?method=backup_codes');
159 $resp->assertElementExists('form[action$="/mfa/verify/backup_codes"] input[name="code"]');
160 $resp->assertElementContains('a[href$="/mfa/verify?method=totp"]', 'Verify using a mobile app');
163 // TODO !! - Test no-existing MFA
166 * @return Array<User, string, TestResponse>
168 protected function startTotpLogin(): array
170 $secret = $this->app->make(TotpService::class)->generateSecret();
171 $user = $this->getEditor();
172 $user->password = Hash::make('password');
174 MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
175 $loginResp = $this->post('/login', [
176 'email' => $user->email,
177 'password' => 'password',
180 return [$user, $secret, $loginResp];
184 * @return Array<User, string, TestResponse>
186 protected function startBackupCodeLogin($codes = ['kzzu6-1pgll','bzxnf-plygd','bwdsp-ysl51','1vo93-ioy7n','lf7nw-wdyka','xmtrd-oplac']): array
188 $user = $this->getEditor();
189 $user->password = Hash::make('password');
191 MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
192 $loginResp = $this->post('/login', [
193 'email' => $user->email,
194 'password' => 'password',
197 return [$user, $codes, $loginResp];