Added TOTP verification upon access

This commit is contained in:
Dan Brown 2021-08-02 15:04:43 +01:00
parent 1af5bbf3f7
commit a3f19ebe96
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 146 additions and 17 deletions

View File

@ -70,7 +70,7 @@ class LoginService
*/
public function reattemptLoginFor(User $user, string $method)
{
if ($user->id !== $this->getLastLoginAttemptUser()) {
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
throw new Exception('Login reattempt user does align with current session state');
}

View File

@ -44,10 +44,24 @@ class MfaValue extends Model
$mfaVal->save();
}
/**
* Easily get the decrypted MFA value for the given user and method.
*/
public static function getValueForUser(User $user, string $method): ?string
{
/** @var MfaValue $mfaVal */
$mfaVal = static::query()
->where('user_id', '=', $user->id)
->where('method', '=', $method)
->first();
return $mfaVal ? $mfaVal->getValue() : null;
}
/**
* Decrypt the value attribute upon access.
*/
public function getValue(): string
protected function getValue(): string
{
return decrypt($this->value);
}
@ -55,7 +69,7 @@ class MfaValue extends Model
/**
* Encrypt the value attribute upon access.
*/
public function setValue($value): void
protected function setValue($value): void
{
$this->value = encrypt($value);
}

View File

@ -8,6 +8,9 @@ use BookStack\Exceptions\NotFoundException;
trait HandlesPartialLogins
{
/**
* @throws NotFoundException
*/
protected function currentOrLastAttemptedUser(): User
{
$loginService = app()->make(LoginService::class);

View File

@ -3,6 +3,8 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Auth\Access\Mfa\TotpService;
use BookStack\Auth\Access\Mfa\TotpValidationRule;
@ -61,4 +63,27 @@ class MfaTotpController extends Controller
return redirect('/mfa/setup');
}
/**
* Verify the MFA method submission on check.
* @throws NotFoundException
*/
public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession)
{
$user = $this->currentOrLastAttemptedUser();
$totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP);
$this->validate($request, [
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
]
]);
$mfaSession->markVerifiedForUser($user);
$loginService->reattemptLoginFor($user, 'mfa-totp');
return redirect()->intended();
}
}

View File

@ -19,24 +19,15 @@
You'll need to set up at least one method before you gain access.
</p>
<div>
<a href="{{ url('/mfa/verify/totp') }}" class="button outline">Configure</a>
<a href="{{ url('/mfa/setup') }}" class="button outline">Configure</a>
</div>
@endif
<div class="setting-list">
<div class="grid half gap-xl">
<div>
<div class="setting-list-label">METHOD A</div>
<p class="small">
...
</p>
</div>
<div class="pt-m">
<a href="{{ url('/mfa/verify/totp') }}" class="button outline">BUTTON</a>
</div>
</div>
</div>
@if($method)
<hr class="my-l">
@include('mfa.verify.' . $method)
@endif
@if(count($otherMethods) > 0)
<hr class="my-l">

View File

@ -0,0 +1,20 @@
<div class="setting-list-label">Mobile App</div>
<p class="small mb-m">
Enter the code, generated using your mobile app, below:
</p>
<form action="{{ url('/mfa/verify/totp') }}" method="post">
{{ 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'))
<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>

View File

@ -235,6 +235,7 @@ 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');
Route::get('/mfa/verify', 'Auth\MfaController@verify');
Route::post('/mfa/verify/totp', 'Auth\MfaTotpController@verify');
// Social auth routes
Route::get('/login/service/{socialDriver}', 'Auth\SocialController@login');

View File

@ -0,0 +1,75 @@
<?php
namespace Tests\Auth;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Auth\Access\Mfa\TotpService;
use BookStack\Auth\User;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
use Tests\TestResponse;
class MfaVerificationTest extends TestCase
{
public function test_totp_verification()
{
[$user, $secret, $loginResp] = $this->startTotpLogin();
$loginResp->assertRedirect('/mfa/verify');
$resp = $this->get('/mfa/verify');
$resp->assertSee('Verify Access');
$resp->assertSee('Enter the code, generated using your mobile app, below:');
$resp->assertElementExists('form[action$="/mfa/verify/totp"] input[name="code"]');
$google2fa = new Google2FA();
$resp = $this->post('/mfa/verify/totp', [
'code' => $google2fa->getCurrentOtp($secret),
]);
$resp->assertRedirect('/');
$this->assertEquals($user->id, auth()->user()->id);
}
public function test_totp_verification_fails_on_missing_invalid_code()
{
[$user, $secret, $loginResp] = $this->startTotpLogin();
$resp = $this->get('/mfa/verify');
$resp = $this->post('/mfa/verify/totp', [
'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/totp', [
'code' => '123321',
]);
$resp->assertRedirect('/mfa/verify');
$resp = $this->get('/mfa/verify');
$resp->assertSeeText('The provided code is not valid or has expired.');
$this->assertNull(auth()->user());
}
/**
* @return Array<User, string, TestResponse>
*/
protected function startTotpLogin(): array
{
$secret = $this->app->make(TotpService::class)->generateSecret();
$user = $this->getEditor();
$user->password = Hash::make('password');
$user->save();
MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
$loginResp = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
return [$user, $secret, $loginResp];
}
}