Added backup code setup flow

- Includes testing to cover flow.
- Moved TOTP logic to its own controller.
- Added some extra totp tests.
This commit is contained in:
Dan Brown 2021-07-02 20:53:33 +01:00
parent 83c8f73142
commit 529971c534
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
10 changed files with 242 additions and 57 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace BookStack\Auth\Access\Mfa;
use Illuminate\Support\Str;
class BackupCodeService
{
/**
* Generate a new set of 16 backup codes.
*/
public function generateNewSet(): array
{
$codes = [];
for ($i = 0; $i < 16; $i++) {
$code = Str::random(5) . '-' . Str::random(5);
$codes[] = strtolower($code);
}
return $codes;
}
}

View File

@ -19,7 +19,7 @@ class MfaValue extends Model
protected static $unguarded = true;
const METHOD_TOTP = 'totp';
const METHOD_CODES = 'codes';
const METHOD_BACKUP_CODES = 'backup_codes';
/**
* Upsert a new MFA value for the given user and method

View File

@ -0,0 +1,47 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\BackupCodeService;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Http\Controllers\Controller;
use Exception;
class MfaBackupCodesController extends Controller
{
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes';
/**
* Show a view that generates and displays backup codes
*/
public function generate(BackupCodeService $codeService)
{
$codes = $codeService->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');
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,60 @@
<?php
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 MfaTotpController extends Controller
{
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
/**
* Show a view that generates and displays a TOTP QR code.
*/
public function generate(TotpService $totp)
{
if (session()->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');
}
}

View File

@ -0,0 +1,40 @@
@extends('simple-layout')
@section('body')
<div class="container very-small py-xl">
<div class="card content-wrap auto-height">
<h1 class="list-heading">Backup Codes</h1>
<p>
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.
</p>
<div class="text-center mb-xs">
<div class="text-bigger code-base p-m" style="column-count: 2">
@foreach($codes as $code)
{{ $code }} <br>
@endforeach
</div>
</div>
<p class="text-right">
<a href="{{ $downloadUrl }}" download="backup-codes.txt" class="button outline small">Download Codes</a>
</p>
<p class="callout warning">
Each code can only be used once
</p>
<form action="{{ url('/mfa/backup-codes-confirm') }}" method="POST">
{{ csrf_field() }}
<div class="mt-s text-right">
<a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button class="button">Confirm and Enable</button>
</div>
</form>
</div>
</div>
@stop

View File

@ -36,12 +36,20 @@
<div>
<div class="setting-list-label">Backup Codes</div>
<p class="small">
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.
</p>
</div>
<div class="pt-m">
<a href="{{ url('/mfa/codes/generate') }}" class="button outline">Setup</a>
@if($userMethods->has('backup_codes'))
<div class="text-pos">
@icon('check-circle')
Already configured
</div>
<a href="{{ url('/mfa/backup-codes-generate') }}" class="button outline small">Reconfigure</a>
@else
<a href="{{ url('/mfa/backup-codes-generate') }}" class="button outline">Setup</a>
@endif
</div>
</div>
</div>

View File

@ -35,6 +35,7 @@
<div class="text-neg text-small px-xs">{{ $errors->first('code') }}</div>
@endif
<div class="mt-s text-right">
<a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button class="button">Confirm and Enable</button>
</div>
</form>

View File

@ -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

View File

@ -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);
}
}