0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-05-05 08:40:11 +00:00

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) 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'); throw new Exception('Login reattempt user does align with current session state');
} }

View file

@ -44,10 +44,24 @@ class MfaValue extends Model
$mfaVal->save(); $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. * Decrypt the value attribute upon access.
*/ */
public function getValue(): string protected function getValue(): string
{ {
return decrypt($this->value); return decrypt($this->value);
} }
@ -55,7 +69,7 @@ class MfaValue extends Model
/** /**
* Encrypt the value attribute upon access. * Encrypt the value attribute upon access.
*/ */
public function setValue($value): void protected function setValue($value): void
{ {
$this->value = encrypt($value); $this->value = encrypt($value);
} }

View file

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

View file

@ -3,6 +3,8 @@
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType; 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\MfaValue;
use BookStack\Auth\Access\Mfa\TotpService; use BookStack\Auth\Access\Mfa\TotpService;
use BookStack\Auth\Access\Mfa\TotpValidationRule; use BookStack\Auth\Access\Mfa\TotpValidationRule;
@ -61,4 +63,27 @@ class MfaTotpController extends Controller
return redirect('/mfa/setup'); 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. You'll need to set up at least one method before you gain access.
</p> </p>
<div> <div>
<a href="{{ url('/mfa/verify/totp') }}" class="button outline">Configure</a> <a href="{{ url('/mfa/setup') }}" class="button outline">Configure</a>
</div> </div>
@endif @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) @if(count($otherMethods) > 0)
<hr class="my-l"> <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::get('/mfa/backup-codes-generate', 'Auth\MfaBackupCodesController@generate');
Route::post('/mfa/backup-codes-confirm', 'Auth\MfaBackupCodesController@confirm'); Route::post('/mfa/backup-codes-confirm', 'Auth\MfaBackupCodesController@confirm');
Route::get('/mfa/verify', 'Auth\MfaController@verify'); Route::get('/mfa/verify', 'Auth\MfaController@verify');
Route::post('/mfa/verify/totp', 'Auth\MfaTotpController@verify');
// Social auth routes // Social auth routes
Route::get('/login/service/{socialDriver}', 'Auth\SocialController@login'); 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];
}
}