diff --git a/app/Auth/Access/Mfa/BackupCodeService.php b/app/Auth/Access/Mfa/BackupCodeService.php new file mode 100644 index 000000000..cc533bd31 --- /dev/null +++ b/app/Auth/Access/Mfa/BackupCodeService.php @@ -0,0 +1,21 @@ +generateNewSet(); + session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($codes)); + + $downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes)); + + return view('mfa.backup-codes-generate', [ + 'codes' => $codes, + 'downloadUrl' => $downloadUrl, + ]); + } + + /** + * Confirm the setup of backup codes, storing them against the user. + * @throws Exception + */ + public function confirm() + { + if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) { + return response('No generated codes found in the session', 500); + } + + $codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY)); + MfaValue::upsertWithValue(user(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes)); + + $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes'); + return redirect('/mfa/setup'); + } +} diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php index 8ddccaa98..caee416d3 100644 --- a/app/Http/Controllers/Auth/MfaController.php +++ b/app/Http/Controllers/Auth/MfaController.php @@ -2,18 +2,10 @@ namespace BookStack\Http\Controllers\Auth; -use BookStack\Actions\ActivityType; -use BookStack\Auth\Access\Mfa\MfaValue; -use BookStack\Auth\Access\Mfa\TotpService; -use BookStack\Auth\Access\Mfa\TotpValidationRule; use BookStack\Http\Controllers\Controller; -use Illuminate\Http\Request; -use Illuminate\Validation\ValidationException; class MfaController extends Controller { - protected const TOTP_SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret'; - /** * Show the view to setup MFA for the current user. */ @@ -26,47 +18,4 @@ class MfaController extends Controller 'userMethods' => $userMethods, ]); } - - /** - * Show a view that generates and displays a TOTP QR code. - */ - public function totpGenerate(TotpService $totp) - { - if (session()->has(static::TOTP_SETUP_SECRET_SESSION_KEY)) { - $totpSecret = decrypt(session()->get(static::TOTP_SETUP_SECRET_SESSION_KEY)); - } else { - $totpSecret = $totp->generateSecret(); - session()->put(static::TOTP_SETUP_SECRET_SESSION_KEY, encrypt($totpSecret)); - } - - $qrCodeUrl = $totp->generateUrl($totpSecret); - $svg = $totp->generateQrCodeSvg($qrCodeUrl); - - return view('mfa.totp-generate', [ - 'secret' => $totpSecret, - 'svg' => $svg, - ]); - } - - /** - * Confirm the setup of TOTP and save the auth method secret - * against the current user. - * @throws ValidationException - */ - public function totpConfirm(Request $request) - { - $totpSecret = decrypt(session()->get(static::TOTP_SETUP_SECRET_SESSION_KEY)); - $this->validate($request, [ - 'code' => [ - 'required', - 'max:12', 'min:4', - new TotpValidationRule($totpSecret), - ] - ]); - - MfaValue::upsertWithValue(user(), MfaValue::METHOD_TOTP, $totpSecret); - $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp'); - - return redirect('/mfa/setup'); - } } diff --git a/app/Http/Controllers/Auth/MfaTotpController.php b/app/Http/Controllers/Auth/MfaTotpController.php new file mode 100644 index 000000000..18f08e709 --- /dev/null +++ b/app/Http/Controllers/Auth/MfaTotpController.php @@ -0,0 +1,60 @@ +has(static::SETUP_SECRET_SESSION_KEY)) { + $totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY)); + } else { + $totpSecret = $totp->generateSecret(); + session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret)); + } + + $qrCodeUrl = $totp->generateUrl($totpSecret); + $svg = $totp->generateQrCodeSvg($qrCodeUrl); + + return view('mfa.totp-generate', [ + 'secret' => $totpSecret, + 'svg' => $svg, + ]); + } + + /** + * Confirm the setup of TOTP and save the auth method secret + * against the current user. + * @throws ValidationException + */ + public function confirm(Request $request) + { + $totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY)); + $this->validate($request, [ + 'code' => [ + 'required', + 'max:12', 'min:4', + new TotpValidationRule($totpSecret), + ] + ]); + + MfaValue::upsertWithValue(user(), MfaValue::METHOD_TOTP, $totpSecret); + session()->remove(static::SETUP_SECRET_SESSION_KEY); + $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp'); + + return redirect('/mfa/setup'); + } +} diff --git a/resources/views/mfa/backup-codes-generate.blade.php b/resources/views/mfa/backup-codes-generate.blade.php new file mode 100644 index 000000000..8b437846e --- /dev/null +++ b/resources/views/mfa/backup-codes-generate.blade.php @@ -0,0 +1,40 @@ +@extends('simple-layout') + +@section('body') + +
+
+

Backup Codes

+

+ Store the below list of codes in a safe place. + When accessing the system you'll be able to use one of the codes + as a second authentication mechanism. +

+ +
+
+ @foreach($codes as $code) + {{ $code }}
+ @endforeach +
+
+ +

+ Download Codes +

+ +

+ Each code can only be used once +

+ +
+ {{ csrf_field() }} +
+ {{ trans('common.cancel') }} + +
+
+
+
+ +@stop diff --git a/resources/views/mfa/setup.blade.php b/resources/views/mfa/setup.blade.php index d8fe50947..c98d78885 100644 --- a/resources/views/mfa/setup.blade.php +++ b/resources/views/mfa/setup.blade.php @@ -36,12 +36,20 @@
Backup Codes

- Print out or securely store a set of one-time backup codes + Securely store a set of one-time-use backup codes which you can enter to verify your identity.

- Setup + @if($userMethods->has('backup_codes')) +
+ @icon('check-circle') + Already configured +
+ Reconfigure + @else + Setup + @endif
diff --git a/resources/views/mfa/totp-generate.blade.php b/resources/views/mfa/totp-generate.blade.php index 17d38adaa..c1e7547d5 100644 --- a/resources/views/mfa/totp-generate.blade.php +++ b/resources/views/mfa/totp-generate.blade.php @@ -35,6 +35,7 @@
{{ $errors->first('code') }}
@endif
+ {{ trans('common.cancel') }}
diff --git a/routes/web.php b/routes/web.php index f9967465b..7ab5890e0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -225,8 +225,10 @@ Route::group(['middleware' => 'auth'], function () { }); Route::get('/mfa/setup', 'Auth\MfaController@setup'); - Route::get('/mfa/totp-generate', 'Auth\MfaController@totpGenerate'); - Route::post('/mfa/totp-confirm', 'Auth\MfaController@totpConfirm'); + Route::get('/mfa/totp-generate', 'Auth\MfaTotpController@generate'); + Route::post('/mfa/totp-confirm', 'Auth\MfaTotpController@confirm'); + Route::get('/mfa/backup-codes-generate', 'Auth\MfaBackupCodesController@generate'); + Route::post('/mfa/backup-codes-confirm', 'Auth\MfaBackupCodesController@confirm'); }); // Social auth routes diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 9407c3735..870850a73 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -2,6 +2,7 @@ namespace Tests\Auth; +use BookStack\Auth\Access\Mfa\MfaValue; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -37,7 +38,8 @@ class MfaConfigurationTest extends TestCase // Successful confirmation $google2fa = new Google2FA(); - $otp = $google2fa->getCurrentOtp(decrypt(session()->get('mfa-setup-totp-secret'))); + $secret = decrypt(session()->get('mfa-setup-totp-secret')); + $otp = $google2fa->getCurrentOtp($secret); $resp = $this->post('/mfa/totp-confirm', [ 'code' => $otp, ]); @@ -47,6 +49,61 @@ class MfaConfigurationTest extends TestCase $resp = $this->followRedirects($resp); $resp->assertSee('Multi-factor method successfully configured'); $resp->assertElementContains('a[href$="/mfa/totp-generate"]', 'Reconfigure'); + + $this->assertDatabaseHas('mfa_values', [ + 'user_id' => $editor->id, + 'method' => 'totp', + ]); + $this->assertFalse(session()->has('mfa-setup-totp-secret')); + $value = MfaValue::query()->where('user_id', '=', $editor->id) + ->where('method', '=', 'totp')->first(); + $this->assertEquals($secret, decrypt($value->value)); + } + + public function test_backup_codes_setup() + { + $editor = $this->getEditor(); + $this->assertDatabaseMissing('mfa_values', ['user_id' => $editor->id]); + + // Setup page state + $resp = $this->actingAs($editor)->get('/mfa/setup'); + $resp->assertElementContains('a[href$="/mfa/backup-codes-generate"]', 'Setup'); + + // Generate page access + $resp = $this->get('/mfa/backup-codes-generate'); + $resp->assertSee('Backup Codes'); + $resp->assertElementContains('form[action$="/mfa/backup-codes-confirm"]', 'Confirm and Enable'); + $this->assertSessionHas('mfa-setup-backup-codes'); + $codes = decrypt(session()->get('mfa-setup-backup-codes')); + // Check code format + $this->assertCount(16, $codes); + $this->assertEquals(16*11, strlen(implode('', $codes))); + // Check download link + $resp->assertSee(base64_encode(implode("\n\n", $codes))); + + // Confirm submit + $resp = $this->post('/mfa/backup-codes-confirm'); + $resp->assertRedirect('/mfa/setup'); + + // Confirmation of setup + $resp = $this->followRedirects($resp); + $resp->assertSee('Multi-factor method successfully configured'); + $resp->assertElementContains('a[href$="/mfa/backup-codes-generate"]', 'Reconfigure'); + + $this->assertDatabaseHas('mfa_values', [ + 'user_id' => $editor->id, + 'method' => 'backup_codes', + ]); + $this->assertFalse(session()->has('mfa-setup-backup-codes')); + $value = MfaValue::query()->where('user_id', '=', $editor->id) + ->where('method', '=', 'backup_codes')->first(); + $this->assertEquals($codes, json_decode(decrypt($value->value))); + } + + public function test_backup_codes_cannot_be_confirmed_if_not_previously_generated() + { + $resp = $this->asEditor()->post('/mfa/backup-codes-confirm'); + $resp->assertStatus(500); } } \ No newline at end of file