mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-30 07:32:39 +01:00
Added TOTP verification upon access
This commit is contained in:
parent
1af5bbf3f7
commit
a3f19ebe96
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -8,6 +8,9 @@ use BookStack\Exceptions\NotFoundException;
|
||||
|
||||
trait HandlesPartialLogins
|
||||
{
|
||||
/**
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
protected function currentOrLastAttemptedUser(): User
|
||||
{
|
||||
$loginService = app()->make(LoginService::class);
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
20
resources/views/mfa/verify/totp.blade.php
Normal file
20
resources/views/mfa/verify/totp.blade.php
Normal 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>
|
@ -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');
|
||||
|
75
tests/Auth/MfaVerificationTest.php
Normal file
75
tests/Auth/MfaVerificationTest.php
Normal 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];
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user