diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php index 9823caafa..cc0cb06f3 100644 --- a/app/Auth/Access/LoginService.php +++ b/app/Auth/Access/LoginService.php @@ -37,6 +37,8 @@ class LoginService throw new StoppedAuthenticationException($user, $this); // TODO - Does 'remember' still work? Probably not right now. + // TODO - Need to clear MFA sessions out upon logout + // Old MFA middleware todos: // TODO - Need to redirect to setup if not configured AND ONLY IF NO OPTIONS CONFIGURED diff --git a/app/Auth/Access/Mfa/BackupCodeService.php b/app/Auth/Access/Mfa/BackupCodeService.php index cc533bd31..6fbbf64b5 100644 --- a/app/Auth/Access/Mfa/BackupCodeService.php +++ b/app/Auth/Access/Mfa/BackupCodeService.php @@ -12,10 +12,55 @@ class BackupCodeService public function generateNewSet(): array { $codes = []; - for ($i = 0; $i < 16; $i++) { + while (count($codes) < 16) { $code = Str::random(5) . '-' . Str::random(5); - $codes[] = strtolower($code); + if (!in_array($code, $codes)) { + $codes[] = strtolower($code); + } } + return $codes; } + + /** + * Check if the given code matches one of the available options. + */ + public function inputCodeExistsInSet(string $code, string $codeSet): bool + { + $cleanCode = $this->cleanInputCode($code); + $codes = json_decode($codeSet); + return in_array($cleanCode, $codes); + } + + /** + * Remove the given input code from the given available options. + * Will return null if no codes remain otherwise will be a JSON string to contain + * the codes. + */ + public function removeInputCodeFromSet(string $code, string $codeSet): ?string + { + $cleanCode = $this->cleanInputCode($code); + $codes = json_decode($codeSet); + $pos = array_search($cleanCode, $codes, true); + array_splice($codes, $pos, 1); + + if (count($codes) === 0) { + return null; + } + + return json_encode($codes); + } + + /** + * Count the number of codes in the given set. + */ + public function countCodesInSet(string $codeSet): int + { + return count(json_decode($codeSet)); + } + + protected function cleanInputCode(string $code): string + { + return strtolower(str_replace(' ', '-', trim($code))); + } } \ No newline at end of file diff --git a/app/Auth/Access/Mfa/MfaValue.php b/app/Auth/Access/Mfa/MfaValue.php index ec0d5d563..228a6d43f 100644 --- a/app/Auth/Access/Mfa/MfaValue.php +++ b/app/Auth/Access/Mfa/MfaValue.php @@ -58,6 +58,17 @@ class MfaValue extends Model return $mfaVal ? $mfaVal->getValue() : null; } + /** + * Delete any stored MFA values for the given user and method. + */ + public static function deleteValuesForUser(User $user, string $method): void + { + static::query() + ->where('user_id', '=', $user->id) + ->where('method', '=', $method) + ->delete(); + } + /** * Decrypt the value attribute upon access. */ diff --git a/app/Http/Controllers/Auth/MfaBackupCodesController.php b/app/Http/Controllers/Auth/MfaBackupCodesController.php index b89050565..1353d4562 100644 --- a/app/Http/Controllers/Auth/MfaBackupCodesController.php +++ b/app/Http/Controllers/Auth/MfaBackupCodesController.php @@ -3,10 +3,15 @@ namespace BookStack\Http\Controllers\Auth; use BookStack\Actions\ActivityType; +use BookStack\Auth\Access\LoginService; use BookStack\Auth\Access\Mfa\BackupCodeService; +use BookStack\Auth\Access\Mfa\MfaSession; use BookStack\Auth\Access\Mfa\MfaValue; +use BookStack\Exceptions\NotFoundException; use BookStack\Http\Controllers\Controller; use Exception; +use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; class MfaBackupCodesController extends Controller { @@ -46,4 +51,39 @@ class MfaBackupCodesController extends Controller $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes'); return redirect('/mfa/setup'); } + + /** + * Verify the MFA method submission on check. + * @throws NotFoundException + * @throws ValidationException + */ + public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService) + { + $user = $this->currentOrLastAttemptedUser(); + $codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]'; + + $this->validate($request, [ + 'code' => [ + 'required', + 'max:12', 'min:8', + function ($attribute, $value, $fail) use ($codeService, $codes) { + if (!$codeService->inputCodeExistsInSet($value, $codes)) { + $fail(trans('validation.backup_codes')); + } + } + ] + ]); + + $updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes); + MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes); + + $mfaSession->markVerifiedForUser($user); + $loginService->reattemptLoginFor($user, 'mfa-backup_codes'); + + if ($codeService->countCodesInSet($updatedCodes) < 5) { + $this->showWarningNotification('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.'); + } + + return redirect()->intended(); + } } diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php index 6d868b6f3..75cd46eef 100644 --- a/app/Http/Controllers/Auth/MfaController.php +++ b/app/Http/Controllers/Auth/MfaController.php @@ -5,7 +5,7 @@ namespace BookStack\Http\Controllers\Auth; use BookStack\Actions\ActivityType; use BookStack\Auth\Access\Mfa\MfaValue; use BookStack\Http\Controllers\Controller; -use BookStack\Http\Request; +use Illuminate\Http\Request; class MfaController extends Controller { @@ -47,7 +47,6 @@ class MfaController extends Controller */ public function verify(Request $request) { - // TODO - Test this $desiredMethod = $request->get('method'); $userMethods = $this->currentOrLastAttemptedUser() ->mfaValues() diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index d64fce93a..2159cfb3b 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -73,5 +73,9 @@ return [ 'user_invite_page_welcome' => 'Welcome to :appName!', 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', 'user_invite_page_confirm_button' => 'Confirm Password', - 'user_invite_success' => 'Password set, you now have access to :appName!' + 'user_invite_success' => 'Password set, you now have access to :appName!', + + // Multi-factor Authentication + 'mfa_use_totp' => 'Verify using a mobile app', + 'mfa_use_backup_codes' => 'Verify using a backup code', ]; \ No newline at end of file diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 5796d04c6..1963b0df2 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -15,6 +15,7 @@ return [ 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', + 'backup_codes' => 'The provided code is not valid or has already been used.', 'before' => 'The :attribute must be a date before :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', diff --git a/resources/views/mfa/verify.blade.php b/resources/views/mfa/verify.blade.php index e818852c2..7cab2edc4 100644 --- a/resources/views/mfa/verify.blade.php +++ b/resources/views/mfa/verify.blade.php @@ -32,7 +32,9 @@ @if(count($otherMethods) > 0) <hr class="my-l"> @foreach($otherMethods as $otherMethod) - <a href="{{ url("/mfa/verify?method={$otherMethod}") }}">Use {{$otherMethod}}</a> + <div class="text-center"> + <a href="{{ url("/mfa/verify?method={$otherMethod}") }}">{{ trans('auth.mfa_use_' . $otherMethod) }}</a> + </div> @endforeach @endif diff --git a/resources/views/mfa/verify/backup_codes.blade.php b/resources/views/mfa/verify/backup_codes.blade.php new file mode 100644 index 000000000..abfb8a9d0 --- /dev/null +++ b/resources/views/mfa/verify/backup_codes.blade.php @@ -0,0 +1,19 @@ +<div class="setting-list-label">Backup Code</div> + +<p class="small mb-m"> + Enter one of your remaining backup codes below: +</p> + +<form action="{{ url('/mfa/verify/backup_codes') }}" method="post"> + {{ csrf_field() }} + <input type="text" + name="code" + placeholder="Enter backup code here" + class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}"> + @if($errors->has('code')) + <div class="text-neg text-small px-xs">{{ $errors->first('code') }}</div> + @endif + <div class="mt-s text-right"> + <button class="button">{{ trans('common.confirm') }}</button> + </div> +</form> \ No newline at end of file diff --git a/resources/views/mfa/verify/totp.blade.php b/resources/views/mfa/verify/totp.blade.php index 7ff691552..55784dc8f 100644 --- a/resources/views/mfa/verify/totp.blade.php +++ b/resources/views/mfa/verify/totp.blade.php @@ -8,7 +8,6 @@ {{ csrf_field() }} <input type="text" name="code" - aria-labelledby="totp-verify-input-details" placeholder="Provide your app generated code here" class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}"> @if($errors->has('code')) diff --git a/routes/web.php b/routes/web.php index 029649685..eadbca5e8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -236,6 +236,7 @@ Route::get('/mfa/backup-codes-generate', 'Auth\MfaBackupCodesController@generate Route::post('/mfa/backup-codes-confirm', 'Auth\MfaBackupCodesController@confirm'); Route::get('/mfa/verify', 'Auth\MfaController@verify'); Route::post('/mfa/verify/totp', 'Auth\MfaTotpController@verify'); +Route::post('/mfa/verify/backup_codes', 'Auth\MfaBackupCodesController@verify'); // Social auth routes Route::get('/login/service/{socialDriver}', 'Auth\SocialController@login'); diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index f19700a5a..3f272cffb 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -54,6 +54,114 @@ class MfaVerificationTest extends TestCase $this->assertNull(auth()->user()); } + public function test_backup_code_verification() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); + $loginResp->assertRedirect('/mfa/verify'); + + $resp = $this->get('/mfa/verify'); + $resp->assertSee('Verify Access'); + $resp->assertSee('Backup Code'); + $resp->assertSee('Enter one of your remaining backup codes below:'); + $resp->assertElementExists('form[action$="/mfa/verify/backup_codes"] input[name="code"]'); + + $resp = $this->post('/mfa/verify/backup_codes', [ + 'code' => $codes[1], + ]); + + $resp->assertRedirect('/'); + $this->assertEquals($user->id, auth()->user()->id); + // Ensure code no longer exists in available set + $userCodes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES); + $this->assertStringNotContainsString($codes[1], $userCodes); + $this->assertStringContainsString($codes[0], $userCodes); + } + + public function test_backup_code_verification_fails_on_missing_or_invalid_code() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); + + $resp = $this->get('/mfa/verify'); + $resp = $this->post('/mfa/verify/backup_codes', [ + 'code' => '', + ]); + $resp->assertRedirect('/mfa/verify'); + + $resp = $this->get('/mfa/verify'); + $resp->assertSeeText('The code field is required.'); + $this->assertNull(auth()->user()); + + $resp = $this->post('/mfa/verify/backup_codes', [ + 'code' => 'ab123-ab456', + ]); + $resp->assertRedirect('/mfa/verify'); + + $resp = $this->get('/mfa/verify'); + $resp->assertSeeText('The provided code is not valid or has already been used.'); + $this->assertNull(auth()->user()); + } + + public function test_backup_code_verification_fails_on_attempted_code_reuse() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); + + $this->post('/mfa/verify/backup_codes', [ + 'code' => $codes[0], + ]); + $this->assertNotNull(auth()->user()); + auth()->logout(); + session()->flush(); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->get('/mfa/verify'); + $resp = $this->post('/mfa/verify/backup_codes', [ + 'code' => $codes[0], + ]); + $resp->assertRedirect('/mfa/verify'); + $this->assertNull(auth()->user()); + + $resp = $this->get('/mfa/verify'); + $resp->assertSeeText('The provided code is not valid or has already been used.'); + } + + public function test_backup_code_verification_shows_warning_when_limited_codes_remain() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']); + + $resp = $this->post('/mfa/verify/backup_codes', [ + 'code' => $codes[0], + ]); + $resp = $this->followRedirects($resp); + $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.'); + } + + public function test_both_mfa_options_available_if_set_on_profile() + { + $user = $this->getEditor(); + $user->password = Hash::make('password'); + $user->save(); + + MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123'); + MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]'); + + /** @var TestResponse $mfaView */ + $mfaView = $this->followingRedirects()->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + // Totp shown by default + $mfaView->assertElementExists('form[action$="/mfa/verify/totp"] input[name="code"]'); + $mfaView->assertElementContains('a[href$="/mfa/verify?method=backup_codes"]', 'Verify using a backup code'); + + // Ensure can view backup_codes view + $resp = $this->get('/mfa/verify?method=backup_codes'); + $resp->assertElementExists('form[action$="/mfa/verify/backup_codes"] input[name="code"]'); + $resp->assertElementContains('a[href$="/mfa/verify?method=totp"]', 'Verify using a mobile app'); + } + + // TODO !! - Test no-existing MFA + /** * @return Array<User, string, TestResponse> */ @@ -72,4 +180,21 @@ class MfaVerificationTest extends TestCase return [$user, $secret, $loginResp]; } + /** + * @return Array<User, string, TestResponse> + */ + protected function startBackupCodeLogin($codes = ['kzzu6-1pgll','bzxnf-plygd','bwdsp-ysl51','1vo93-ioy7n','lf7nw-wdyka','xmtrd-oplac']): array + { + $user = $this->getEditor(); + $user->password = Hash::make('password'); + $user->save(); + MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes)); + $loginResp = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + return [$user, $codes, $loginResp]; + } + } \ No newline at end of file