mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
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:
parent
83c8f73142
commit
529971c534
21
app/Auth/Access/Mfa/BackupCodeService.php
Normal file
21
app/Auth/Access/Mfa/BackupCodeService.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
47
app/Http/Controllers/Auth/MfaBackupCodesController.php
Normal file
47
app/Http/Controllers/Auth/MfaBackupCodesController.php
Normal 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');
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
60
app/Http/Controllers/Auth/MfaTotpController.php
Normal file
60
app/Http/Controllers/Auth/MfaTotpController.php
Normal 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');
|
||||
}
|
||||
}
|
40
resources/views/mfa/backup-codes-generate.blade.php
Normal file
40
resources/views/mfa/backup-codes-generate.blade.php
Normal 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
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user