*/
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');
}
$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);
}
/**
* Encrypt the value attribute upon access.
*/
- public function setValue($value): void
+ protected function setValue($value): void
{
$this->value = encrypt($value);
}
trait HandlesPartialLogins
{
+ /**
+ * @throws NotFoundException
+ */
protected function currentOrLastAttemptedUser(): User
{
$loginService = app()->make(LoginService::class);
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;
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();
+ }
}
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">
--- /dev/null
+<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>
\ No newline at end of file
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');
--- /dev/null
+<?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];
+ }
+
+}
\ No newline at end of file