]> BookStack Code Mirror - bookstack/blob - tests/Auth/MfaVerificationTest.php
3f272cffbcb043360429041689651518df9e9c2c
[bookstack] / tests / Auth / MfaVerificationTest.php
1 <?php
2
3 namespace Tests\Auth;
4
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;
10 use Tests\TestCase;
11 use Tests\TestResponse;
12
13 class MfaVerificationTest extends TestCase
14 {
15     public function test_totp_verification()
16     {
17         [$user, $secret, $loginResp] = $this->startTotpLogin();
18         $loginResp->assertRedirect('/mfa/verify');
19
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"]');
24
25         $google2fa = new Google2FA();
26         $resp = $this->post('/mfa/verify/totp', [
27             'code' => $google2fa->getCurrentOtp($secret),
28         ]);
29         $resp->assertRedirect('/');
30         $this->assertEquals($user->id, auth()->user()->id);
31     }
32
33     public function test_totp_verification_fails_on_missing_invalid_code()
34     {
35         [$user, $secret, $loginResp] = $this->startTotpLogin();
36
37         $resp = $this->get('/mfa/verify');
38         $resp = $this->post('/mfa/verify/totp', [
39             'code' => '',
40         ]);
41         $resp->assertRedirect('/mfa/verify');
42
43         $resp = $this->get('/mfa/verify');
44         $resp->assertSeeText('The code field is required.');
45         $this->assertNull(auth()->user());
46
47         $resp = $this->post('/mfa/verify/totp', [
48             'code' => '123321',
49         ]);
50         $resp->assertRedirect('/mfa/verify');
51         $resp = $this->get('/mfa/verify');
52
53         $resp->assertSeeText('The provided code is not valid or has expired.');
54         $this->assertNull(auth()->user());
55     }
56
57     public function test_backup_code_verification()
58     {
59         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
60         $loginResp->assertRedirect('/mfa/verify');
61
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"]');
67
68         $resp = $this->post('/mfa/verify/backup_codes', [
69             'code' => $codes[1],
70         ]);
71
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);
78     }
79
80     public function test_backup_code_verification_fails_on_missing_or_invalid_code()
81     {
82         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
83
84         $resp = $this->get('/mfa/verify');
85         $resp = $this->post('/mfa/verify/backup_codes', [
86             'code' => '',
87         ]);
88         $resp->assertRedirect('/mfa/verify');
89
90         $resp = $this->get('/mfa/verify');
91         $resp->assertSeeText('The code field is required.');
92         $this->assertNull(auth()->user());
93
94         $resp = $this->post('/mfa/verify/backup_codes', [
95             'code' => 'ab123-ab456',
96         ]);
97         $resp->assertRedirect('/mfa/verify');
98
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());
102     }
103
104     public function test_backup_code_verification_fails_on_attempted_code_reuse()
105     {
106         [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
107
108         $this->post('/mfa/verify/backup_codes', [
109             'code' => $codes[0],
110         ]);
111         $this->assertNotNull(auth()->user());
112         auth()->logout();
113         session()->flush();
114
115         $this->post('/login', ['email' => $user->email, 'password' => 'password']);
116         $this->get('/mfa/verify');
117         $resp = $this->post('/mfa/verify/backup_codes', [
118             'code' => $codes[0],
119         ]);
120         $resp->assertRedirect('/mfa/verify');
121         $this->assertNull(auth()->user());
122
123         $resp = $this->get('/mfa/verify');
124         $resp->assertSeeText('The provided code is not valid or has already been used.');
125     }
126
127     public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
128     {
129         [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
130
131         $resp = $this->post('/mfa/verify/backup_codes', [
132             'code' => $codes[0],
133         ]);
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.');
136     }
137
138     public function test_both_mfa_options_available_if_set_on_profile()
139     {
140         $user = $this->getEditor();
141         $user->password = Hash::make('password');
142         $user->save();
143
144         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
145         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
146
147         /** @var TestResponse $mfaView */
148         $mfaView = $this->followingRedirects()->post('/login', [
149             'email' => $user->email,
150             'password' => 'password',
151         ]);
152
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');
156
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');
161     }
162
163     //    TODO !! - Test no-existing MFA
164
165     /**
166      * @return Array<User, string, TestResponse>
167      */
168     protected function startTotpLogin(): array
169     {
170         $secret = $this->app->make(TotpService::class)->generateSecret();
171         $user = $this->getEditor();
172         $user->password = Hash::make('password');
173         $user->save();
174         MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
175         $loginResp = $this->post('/login', [
176             'email' => $user->email,
177             'password' => 'password',
178         ]);
179
180         return [$user, $secret, $loginResp];
181     }
182
183     /**
184      * @return Array<User, string, TestResponse>
185      */
186     protected function startBackupCodeLogin($codes = ['kzzu6-1pgll','bzxnf-plygd','bwdsp-ysl51','1vo93-ioy7n','lf7nw-wdyka','xmtrd-oplac']): array
187     {
188         $user = $this->getEditor();
189         $user->password = Hash::make('password');
190         $user->save();
191         MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
192         $loginResp = $this->post('/login', [
193             'email' => $user->email,
194             'password' => 'password',
195         ]);
196
197         return [$user, $codes, $loginResp];
198     }
199
200 }