From efb6a6b457ac8e20bbbb39d8a730921850c2751a Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Mon, 28 Jun 2021 22:02:45 +0100
Subject: [PATCH 01/25] Started barebones work of MFA system
---
app/Http/Controllers/Auth/MfaController.php | 29 +++
composer.json | 2 +
composer.lock | 221 +++++++++++++++++++-
resources/views/mfa/setup.blade.php | 16 ++
routes/web.php | 2 +
5 files changed, 269 insertions(+), 1 deletion(-)
create mode 100644 app/Http/Controllers/Auth/MfaController.php
create mode 100644 resources/views/mfa/setup.blade.php
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
new file mode 100644
index 000000000..1d0dbd1a4
--- /dev/null
+++ b/app/Http/Controllers/Auth/MfaController.php
@@ -0,0 +1,29 @@
+
+
+
+
Setup Multi-Factor Authentication
+
+ Setup multi-factor authentication as an extra layer of security
+ for your user account.
+ To use multi-factor authentication you'll need a mobile application
+ that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.
+
+
+
+@stop
diff --git a/routes/web.php b/routes/web.php
index bc9705e10..7807d5477 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -223,6 +223,8 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/roles/{id}', 'RoleController@edit');
Route::put('/roles/{id}', 'RoleController@update');
});
+
+ Route::get('/mfa/setup', 'Auth\MfaController@setup');
});
// Social auth routes
From d25cd83d8e2a8741a6476ce2d7ff6efc728ecc6e Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Tue, 29 Jun 2021 22:06:49 +0100
Subject: [PATCH 02/25] Added TOTP generation view and started verification
stage
Also updated MFA setup view to have settings-like listed interface to
make it possible to extend with extra options in the future.
---
app/Http/Controllers/Auth/MfaController.php | 65 +++++++++++++++++++--
resources/sass/_forms.scss | 4 ++
resources/views/mfa/setup.blade.php | 33 ++++++++++-
resources/views/mfa/totp-generate.blade.php | 44 ++++++++++++++
routes/web.php | 2 +
5 files changed, 141 insertions(+), 7 deletions(-)
create mode 100644 resources/views/mfa/totp-generate.blade.php
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
index 1d0dbd1a4..be381e3b0 100644
--- a/app/Http/Controllers/Auth/MfaController.php
+++ b/app/Http/Controllers/Auth/MfaController.php
@@ -2,11 +2,24 @@
namespace BookStack\Http\Controllers\Auth;
+use BaconQrCode\Renderer\Color\Rgb;
+use BaconQrCode\Renderer\Image\SvgImageBackEnd;
+use BaconQrCode\Renderer\ImageRenderer;
+use BaconQrCode\Renderer\RendererStyle\Fill;
+use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Writer;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
+use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
+use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
+use PragmaRX\Google2FA\Google2FA;
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.
*/
@@ -17,13 +30,57 @@ class MfaController extends Controller
return view('mfa.setup');
}
- public function generateQr()
+ /**
+ * Show a view that generates and displays a TOTP QR code.
+ * @throws IncompatibleWithGoogleAuthenticatorException
+ * @throws InvalidCharactersException
+ * @throws SecretKeyTooShortException
+ */
+ public function totpGenerate()
{
- // https://github.com/antonioribeiro/google2fa#how-to-generate-and-use-two-factor-authentication
+ // TODO - Ensure a QR code doesn't already exist? Or overwrite?
+ $google2fa = new Google2FA();
+ if (session()->has(static::TOTP_SETUP_SECRET_SESSION_KEY)) {
+ $totpSecret = decrypt(session()->get(static::TOTP_SETUP_SECRET_SESSION_KEY));
+ } else {
+ $totpSecret = $google2fa->generateSecretKey();
+ session()->put(static::TOTP_SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
+ }
+
+ $qrCodeUrl = $google2fa->getQRCodeUrl(
+ setting('app-name'),
+ user()->email,
+ $totpSecret
+ );
+
+ $color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
+ $svg = (new Writer(
+ new ImageRenderer(
+ new RendererStyle(192, 0, null, null, $color),
+ new SvgImageBackEnd
+ )
+ ))->writeString($qrCodeUrl);
- // Generate secret key
- // Store key in session?
// Get user to verify setup via responding once.
// If correct response, Save key against user
+ 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)
+ {
+ $this->validate($request, [
+ 'code' => 'required|max:12|min:4'
+ ]);
+
+ // TODO - Confirm code
+ dd($request->input('code'));
}
}
diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss
index 953d1d060..bb6d17f82 100644
--- a/resources/sass/_forms.scss
+++ b/resources/sass/_forms.scss
@@ -29,6 +29,10 @@
}
}
+.input-fill-width {
+ width: 100% !important;
+}
+
.fake-input {
@extend .input-base;
overflow: auto;
diff --git a/resources/views/mfa/setup.blade.php b/resources/views/mfa/setup.blade.php
index 25eb5d925..577841af5 100644
--- a/resources/views/mfa/setup.blade.php
+++ b/resources/views/mfa/setup.blade.php
@@ -5,12 +5,39 @@
Setup Multi-Factor Authentication
-
+
Setup multi-factor authentication as an extra layer of security
for your user account.
- To use multi-factor authentication you'll need a mobile application
- that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.
+
+
+
+
+
Mobile App
+
+ To use multi-factor authentication you'll need a mobile application
+ that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.
+
+ To use multi-factor authentication you'll need a mobile application
+ that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.
+
+
+ Scan the QR code below using your preferred authentication app to get started.
+
+
+
+
+ {!! $svg !!}
+
+
+
+
Verify Setup
+
+ Verify that all is working by entering a code, generated within your
+ authentication app, in the input box below:
+
+
+
+
+
+@stop
diff --git a/routes/web.php b/routes/web.php
index 7807d5477..f9967465b 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -225,6 +225,8 @@ 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');
});
// Social auth routes
From 916a82616f1e2c750a1f01109e65ad2b603a79ce Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Wed, 30 Jun 2021 22:10:02 +0100
Subject: [PATCH 03/25] Complete base flow for TOTP setup
- Includes DB storage and code validation.
- Extracted TOTP work to its own service file.
- Still needs testing to cover this side of things.
---
app/Actions/ActivityType.php | 2 +
app/Auth/Access/Mfa/MfaValue.php | 54 +++++++++++++++
app/Auth/Access/Mfa/TotpService.php | 66 +++++++++++++++++++
app/Auth/Access/Mfa/TotpValidationRule.php | 38 +++++++++++
app/Auth/User.php | 9 +++
app/Auth/UserRepo.php | 1 +
app/Http/Controllers/Auth/MfaController.php | 62 +++++++----------
...1_06_30_173111_create_mfa_values_table.php | 34 ++++++++++
resources/lang/en/activities.php | 3 +
resources/lang/en/validation.php | 1 +
resources/views/mfa/setup.blade.php | 10 ++-
tests/Auth/MfaTotpTest.php | 10 +++
12 files changed, 251 insertions(+), 39 deletions(-)
create mode 100644 app/Auth/Access/Mfa/MfaValue.php
create mode 100644 app/Auth/Access/Mfa/TotpService.php
create mode 100644 app/Auth/Access/Mfa/TotpValidationRule.php
create mode 100644 database/migrations/2021_06_30_173111_create_mfa_values_table.php
create mode 100644 tests/Auth/MfaTotpTest.php
diff --git a/app/Actions/ActivityType.php b/app/Actions/ActivityType.php
index a19f143b7..e2e7b5a5d 100644
--- a/app/Actions/ActivityType.php
+++ b/app/Actions/ActivityType.php
@@ -50,4 +50,6 @@ class ActivityType
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
const AUTH_LOGIN = 'auth_login';
const AUTH_REGISTER = 'auth_register';
+
+ const MFA_SETUP_METHOD = 'mfa_setup_method';
}
diff --git a/app/Auth/Access/Mfa/MfaValue.php b/app/Auth/Access/Mfa/MfaValue.php
new file mode 100644
index 000000000..6e9049c3c
--- /dev/null
+++ b/app/Auth/Access/Mfa/MfaValue.php
@@ -0,0 +1,54 @@
+firstOrNew([
+ 'user_id' => $user->id,
+ 'method' => $method
+ ]);
+ $mfaVal->setValue($value);
+ $mfaVal->save();
+ }
+
+ /**
+ * Decrypt the value attribute upon access.
+ */
+ public function getValue(): string
+ {
+ return decrypt($this->value);
+ }
+
+ /**
+ * Encrypt the value attribute upon access.
+ */
+ public function setValue($value): void
+ {
+ $this->value = encrypt($value);
+ }
+}
diff --git a/app/Auth/Access/Mfa/TotpService.php b/app/Auth/Access/Mfa/TotpService.php
new file mode 100644
index 000000000..f9a9f416e
--- /dev/null
+++ b/app/Auth/Access/Mfa/TotpService.php
@@ -0,0 +1,66 @@
+google2fa = $google2fa;
+ }
+
+ /**
+ * Generate a new totp secret key.
+ */
+ public function generateSecret(): string
+ {
+ /** @noinspection PhpUnhandledExceptionInspection */
+ return $this->google2fa->generateSecretKey();
+ }
+
+ /**
+ * Generate a TOTP URL from secret key.
+ */
+ public function generateUrl(string $secret): string
+ {
+ return $this->google2fa->getQRCodeUrl(
+ setting('app-name'),
+ user()->email,
+ $secret
+ );
+ }
+
+ /**
+ * Generate a QR code to display a TOTP URL.
+ */
+ public function generateQrCodeSvg(string $url): string
+ {
+ $color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
+ return (new Writer(
+ new ImageRenderer(
+ new RendererStyle(192, 0, null, null, $color),
+ new SvgImageBackEnd
+ )
+ ))->writeString($url);
+ }
+
+ /**
+ * Verify that the user provided code is valid for the secret.
+ * The secret must be known, not user-provided.
+ */
+ public function verifyCode(string $code, string $secret): bool
+ {
+ /** @noinspection PhpUnhandledExceptionInspection */
+ return $this->google2fa->verifyKey($secret, $code);
+ }
+}
\ No newline at end of file
diff --git a/app/Auth/Access/Mfa/TotpValidationRule.php b/app/Auth/Access/Mfa/TotpValidationRule.php
new file mode 100644
index 000000000..7fe3d8504
--- /dev/null
+++ b/app/Auth/Access/Mfa/TotpValidationRule.php
@@ -0,0 +1,38 @@
+secret = $secret;
+ $this->totpService = app()->make(TotpService::class);
+ }
+
+ /**
+ * Determine if the validation rule passes.
+ */
+ public function passes($attribute, $value)
+ {
+ return $this->totpService->verifyCode($value, $this->secret);
+ }
+
+ /**
+ * Get the validation error message.
+ */
+ public function message()
+ {
+ return trans('validation.totp');
+ }
+}
diff --git a/app/Auth/User.php b/app/Auth/User.php
index 1a3560691..f4fd45281 100644
--- a/app/Auth/User.php
+++ b/app/Auth/User.php
@@ -4,6 +4,7 @@ namespace BookStack\Auth;
use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken;
+use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
@@ -265,6 +266,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->hasMany(Favourite::class);
}
+ /**
+ * Get the MFA values belonging to this use.
+ */
+ public function mfaValues(): HasMany
+ {
+ return $this->hasMany(MfaValue::class);
+ }
+
/**
* Get the last activity time for this user.
*/
diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php
index 61ca12dcc..9faeb8ae2 100644
--- a/app/Auth/UserRepo.php
+++ b/app/Auth/UserRepo.php
@@ -188,6 +188,7 @@ class UserRepo
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
+ $user->mfaValues()->delete();
$user->delete();
// Delete user profile images
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
index be381e3b0..8ddccaa98 100644
--- a/app/Http/Controllers/Auth/MfaController.php
+++ b/app/Http/Controllers/Auth/MfaController.php
@@ -2,19 +2,13 @@
namespace BookStack\Http\Controllers\Auth;
-use BaconQrCode\Renderer\Color\Rgb;
-use BaconQrCode\Renderer\Image\SvgImageBackEnd;
-use BaconQrCode\Renderer\ImageRenderer;
-use BaconQrCode\Renderer\RendererStyle\Fill;
-use BaconQrCode\Renderer\RendererStyle\RendererStyle;
-use BaconQrCode\Writer;
+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;
-use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
-use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
-use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
-use PragmaRX\Google2FA\Google2FA;
class MfaController extends Controller
{
@@ -25,44 +19,29 @@ class MfaController extends Controller
*/
public function setup()
{
- // TODO - Redirect back to profile/edit if already setup?
- // Show MFA setup route
- return view('mfa.setup');
+ $userMethods = user()->mfaValues()
+ ->get(['id', 'method'])
+ ->groupBy('method');
+ return view('mfa.setup', [
+ 'userMethods' => $userMethods,
+ ]);
}
/**
* Show a view that generates and displays a TOTP QR code.
- * @throws IncompatibleWithGoogleAuthenticatorException
- * @throws InvalidCharactersException
- * @throws SecretKeyTooShortException
*/
- public function totpGenerate()
+ public function totpGenerate(TotpService $totp)
{
- // TODO - Ensure a QR code doesn't already exist? Or overwrite?
- $google2fa = new Google2FA();
if (session()->has(static::TOTP_SETUP_SECRET_SESSION_KEY)) {
$totpSecret = decrypt(session()->get(static::TOTP_SETUP_SECRET_SESSION_KEY));
} else {
- $totpSecret = $google2fa->generateSecretKey();
+ $totpSecret = $totp->generateSecret();
session()->put(static::TOTP_SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
}
- $qrCodeUrl = $google2fa->getQRCodeUrl(
- setting('app-name'),
- user()->email,
- $totpSecret
- );
+ $qrCodeUrl = $totp->generateUrl($totpSecret);
+ $svg = $totp->generateQrCodeSvg($qrCodeUrl);
- $color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
- $svg = (new Writer(
- new ImageRenderer(
- new RendererStyle(192, 0, null, null, $color),
- new SvgImageBackEnd
- )
- ))->writeString($qrCodeUrl);
-
- // Get user to verify setup via responding once.
- // If correct response, Save key against user
return view('mfa.totp-generate', [
'secret' => $totpSecret,
'svg' => $svg,
@@ -76,11 +55,18 @@ class MfaController extends Controller
*/
public function totpConfirm(Request $request)
{
+ $totpSecret = decrypt(session()->get(static::TOTP_SETUP_SECRET_SESSION_KEY));
$this->validate($request, [
- 'code' => 'required|max:12|min:4'
+ 'code' => [
+ 'required',
+ 'max:12', 'min:4',
+ new TotpValidationRule($totpSecret),
+ ]
]);
- // TODO - Confirm code
- dd($request->input('code'));
+ MfaValue::upsertWithValue(user(), MfaValue::METHOD_TOTP, $totpSecret);
+ $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp');
+
+ return redirect('/mfa/setup');
}
}
diff --git a/database/migrations/2021_06_30_173111_create_mfa_values_table.php b/database/migrations/2021_06_30_173111_create_mfa_values_table.php
new file mode 100644
index 000000000..937fd31d9
--- /dev/null
+++ b/database/migrations/2021_06_30_173111_create_mfa_values_table.php
@@ -0,0 +1,34 @@
+increments('id');
+ $table->integer('user_id')->index();
+ $table->string('method', 20)->index();
+ $table->text('value');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('mfa_values');
+ }
+}
diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php
index 5917de2cf..2c371729b 100644
--- a/resources/lang/en/activities.php
+++ b/resources/lang/en/activities.php
@@ -47,6 +47,9 @@ return [
'favourite_add_notification' => '":name" has been added to your favourites',
'favourite_remove_notification' => '":name" has been removed from your favourites',
+ // MFA
+ 'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+
// Other
'commented_on' => 'commented on',
'permissions_update' => 'updated permissions',
diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php
index 4031de2ae..5796d04c6 100644
--- a/resources/lang/en/validation.php
+++ b/resources/lang/en/validation.php
@@ -98,6 +98,7 @@ return [
],
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid zone.',
+ 'totp' => 'The provided code is not valid or has expired.',
'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute format is invalid.',
'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.',
diff --git a/resources/views/mfa/setup.blade.php b/resources/views/mfa/setup.blade.php
index 577841af5..d8fe50947 100644
--- a/resources/views/mfa/setup.blade.php
+++ b/resources/views/mfa/setup.blade.php
@@ -20,7 +20,15 @@
diff --git a/tests/Auth/MfaTotpTest.php b/tests/Auth/MfaTotpTest.php
new file mode 100644
index 000000000..f922063d6
--- /dev/null
+++ b/tests/Auth/MfaTotpTest.php
@@ -0,0 +1,10 @@
+
Date: Fri, 2 Jul 2021 19:51:30 +0100
Subject: [PATCH 04/25] Covered TOTP setup with testing
---
tests/Auth/MfaConfigurationTest.php | 52 +++++++++++++++++++++++++++++
tests/Auth/MfaTotpTest.php | 10 ------
tests/TestResponse.php | 8 +++++
3 files changed, 60 insertions(+), 10 deletions(-)
create mode 100644 tests/Auth/MfaConfigurationTest.php
delete mode 100644 tests/Auth/MfaTotpTest.php
diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php
new file mode 100644
index 000000000..9407c3735
--- /dev/null
+++ b/tests/Auth/MfaConfigurationTest.php
@@ -0,0 +1,52 @@
+getEditor();
+ $this->assertDatabaseMissing('mfa_values', ['user_id' => $editor->id]);
+
+ // Setup page state
+ $resp = $this->actingAs($editor)->get('/mfa/setup');
+ $resp->assertElementContains('a[href$="/mfa/totp-generate"]', 'Setup');
+
+ // Generate page access
+ $resp = $this->get('/mfa/totp-generate');
+ $resp->assertSee('Mobile App Setup');
+ $resp->assertSee('Verify Setup');
+ $resp->assertElementExists('form[action$="/mfa/totp-confirm"] button');
+ $this->assertSessionHas('mfa-setup-totp-secret');
+ $svg = $resp->getElementHtml('#main-content .card svg');
+
+ // Validation error, code should remain the same
+ $resp = $this->post('/mfa/totp-confirm', [
+ 'code' => 'abc123',
+ ]);
+ $resp->assertRedirect('/mfa/totp-generate');
+ $resp = $this->followRedirects($resp);
+ $resp->assertSee('The provided code is not valid or has expired.');
+ $revisitSvg = $resp->getElementHtml('#main-content .card svg');
+ $this->assertTrue($svg === $revisitSvg);
+
+ // Successful confirmation
+ $google2fa = new Google2FA();
+ $otp = $google2fa->getCurrentOtp(decrypt(session()->get('mfa-setup-totp-secret')));
+ $resp = $this->post('/mfa/totp-confirm', [
+ 'code' => $otp,
+ ]);
+ $resp->assertRedirect('/mfa/setup');
+
+ // Confirmation of setup
+ $resp = $this->followRedirects($resp);
+ $resp->assertSee('Multi-factor method successfully configured');
+ $resp->assertElementContains('a[href$="/mfa/totp-generate"]', 'Reconfigure');
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Auth/MfaTotpTest.php b/tests/Auth/MfaTotpTest.php
deleted file mode 100644
index f922063d6..000000000
--- a/tests/Auth/MfaTotpTest.php
+++ /dev/null
@@ -1,10 +0,0 @@
-crawlerInstance;
}
+ /**
+ * Get the HTML of the first element at the given selector.
+ */
+ public function getElementHtml(string $selector): string
+ {
+ return $this->crawler()->filter($selector)->first()->outerHtml();
+ }
+
/**
* Assert the response contains the specified element.
*
From 529971c53470c687f4e7c65900fc6f1f92c951c3 Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Fri, 2 Jul 2021 20:53:33 +0100
Subject: [PATCH 05/25] Added backup code setup flow
- Includes testing to cover flow.
- Moved TOTP logic to its own controller.
- Added some extra totp tests.
---
app/Auth/Access/Mfa/BackupCodeService.php | 21 +++++++
app/Auth/Access/Mfa/MfaValue.php | 2 +-
.../Auth/MfaBackupCodesController.php | 47 +++++++++++++++
app/Http/Controllers/Auth/MfaController.php | 51 ----------------
.../Controllers/Auth/MfaTotpController.php | 60 +++++++++++++++++++
.../views/mfa/backup-codes-generate.blade.php | 40 +++++++++++++
resources/views/mfa/setup.blade.php | 12 +++-
resources/views/mfa/totp-generate.blade.php | 1 +
routes/web.php | 6 +-
tests/Auth/MfaConfigurationTest.php | 59 +++++++++++++++++-
10 files changed, 242 insertions(+), 57 deletions(-)
create mode 100644 app/Auth/Access/Mfa/BackupCodeService.php
create mode 100644 app/Http/Controllers/Auth/MfaBackupCodesController.php
create mode 100644 app/Http/Controllers/Auth/MfaTotpController.php
create mode 100644 resources/views/mfa/backup-codes-generate.blade.php
diff --git a/app/Auth/Access/Mfa/BackupCodeService.php b/app/Auth/Access/Mfa/BackupCodeService.php
new file mode 100644
index 000000000..cc533bd31
--- /dev/null
+++ b/app/Auth/Access/Mfa/BackupCodeService.php
@@ -0,0 +1,21 @@
+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');
+ }
+}
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
index 8ddccaa98..caee416d3 100644
--- a/app/Http/Controllers/Auth/MfaController.php
+++ b/app/Http/Controllers/Auth/MfaController.php
@@ -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');
- }
}
diff --git a/app/Http/Controllers/Auth/MfaTotpController.php b/app/Http/Controllers/Auth/MfaTotpController.php
new file mode 100644
index 000000000..18f08e709
--- /dev/null
+++ b/app/Http/Controllers/Auth/MfaTotpController.php
@@ -0,0 +1,60 @@
+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');
+ }
+}
diff --git a/resources/views/mfa/backup-codes-generate.blade.php b/resources/views/mfa/backup-codes-generate.blade.php
new file mode 100644
index 000000000..8b437846e
--- /dev/null
+++ b/resources/views/mfa/backup-codes-generate.blade.php
@@ -0,0 +1,40 @@
+@extends('simple-layout')
+
+@section('body')
+
+
+
+
Backup Codes
+
+ 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.
+
- 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.
Are you sure you want to remove this multi-factor authentication method?
+
+
+
@else
Setup
@endif
diff --git a/routes/web.php b/routes/web.php
index 3be6218b0..e3329edc4 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -230,6 +230,7 @@ Route::group(['middleware' => 'auth'], function () {
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::delete('/mfa/remove/{method}', 'Auth\MfaController@remove');
});
// Social auth routes
diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php
index adeb66189..a8ca815fb 100644
--- a/tests/Auth/MfaConfigurationTest.php
+++ b/tests/Auth/MfaConfigurationTest.php
@@ -2,6 +2,7 @@
namespace Tests\Auth;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Auth\User;
use PragmaRX\Google2FA\Google2FA;
@@ -145,4 +146,22 @@ class MfaConfigurationTest extends TestCase
$resp->assertElementExists('[title="MFA Configured"] svg');
}
+ public function test_remove_mfa_method()
+ {
+ $admin = $this->getAdmin();
+
+ MfaValue::upsertWithValue($admin, MfaValue::METHOD_TOTP, 'test');
+ $this->assertEquals(1, $admin->mfaValues()->count());
+ $resp = $this->actingAs($admin)->get('/mfa/setup');
+ $resp->assertElementExists('form[action$="/mfa/remove/totp"]');
+
+ $resp = $this->delete("/mfa/remove/totp");
+ $resp->assertRedirect("/mfa/setup");
+ $resp = $this->followRedirects($resp);
+ $resp->assertSee('Multi-factor method successfully removed');
+
+ $this->assertActivityExists(ActivityType::MFA_REMOVE_METHOD);
+ $this->assertEquals(0, $admin->mfaValues()->count());
+ }
+
}
\ No newline at end of file
From 78f9c01519d9d4ea1a2aeff6ef1346ca4ee9e6ff Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Fri, 16 Jul 2021 23:23:36 +0100
Subject: [PATCH 11/25] Started on some MFA access-time checks
Discovered some difficult edge cases:
- User image loading in header bar when using local_secure storage
- 404s showing user-specific visible content due to content listing on
404 page since user is in semi-logged in state. Maybe need to go
through and change up how logins are handled to centralise and
provide us better control at login time to prevent any auth level.
---
app/Auth/Access/Mfa/MfaSession.php | 44 +++++++++++++++++++
app/Http/Controllers/Auth/MfaController.php | 14 ++++++
app/Http/Kernel.php | 1 +
.../Middleware/EnforceMfaRequirements.php | 30 +++++++++++--
resources/views/mfa/verify.blade.php | 31 +++++++++++++
routes/web.php | 5 ++-
6 files changed, 120 insertions(+), 5 deletions(-)
create mode 100644 app/Auth/Access/Mfa/MfaSession.php
create mode 100644 resources/views/mfa/verify.blade.php
diff --git a/app/Auth/Access/Mfa/MfaSession.php b/app/Auth/Access/Mfa/MfaSession.php
new file mode 100644
index 000000000..67574cbaf
--- /dev/null
+++ b/app/Auth/Access/Mfa/MfaSession.php
@@ -0,0 +1,44 @@
+mfaValues()->exists() || $this->currentUserRoleEnforcesMfa();
+ }
+
+ /**
+ * Check if a role of the current user enforces MFA.
+ */
+ protected function currentUserRoleEnforcesMfa(): bool
+ {
+ return user()->roles()
+ ->where('mfa_enforced', '=', true)
+ ->exists();
+ }
+
+ /**
+ * Check if the current MFA session has already been verified.
+ */
+ public function isVerified(): bool
+ {
+ return session()->get(self::MFA_VERIFIED_SESSION_KEY) === 'true';
+ }
+
+ /**
+ * Mark the current session as MFA-verified.
+ */
+ public function markVerified(): void
+ {
+ session()->put(self::MFA_VERIFIED_SESSION_KEY, 'true');
+ }
+
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
index 9feda9433..39a4e852f 100644
--- a/app/Http/Controllers/Auth/MfaController.php
+++ b/app/Http/Controllers/Auth/MfaController.php
@@ -37,4 +37,18 @@ class MfaController extends Controller
return redirect('/mfa/setup');
}
+
+ /**
+ * Show the page to start an MFA verification.
+ */
+ public function verify()
+ {
+ $userMethods = user()->mfaValues()
+ ->get(['id', 'method'])
+ ->groupBy('method');
+
+ return view('mfa.verify', [
+ 'userMethods' => $userMethods,
+ ]);
+ }
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 4f9bfc1e6..c9e59ed3e 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -48,6 +48,7 @@ class Kernel extends HttpKernel
*/
protected $routeMiddleware = [
'auth' => \BookStack\Http\Middleware\Authenticate::class,
+ 'mfa' => \BookStack\Http\Middleware\EnforceMfaRequirements::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
diff --git a/app/Http/Middleware/EnforceMfaRequirements.php b/app/Http/Middleware/EnforceMfaRequirements.php
index 957b42ae1..ac3c9609b 100644
--- a/app/Http/Middleware/EnforceMfaRequirements.php
+++ b/app/Http/Middleware/EnforceMfaRequirements.php
@@ -2,10 +2,21 @@
namespace BookStack\Http\Middleware;
+use BookStack\Auth\Access\Mfa\MfaSession;
use Closure;
class EnforceMfaRequirements
{
+ protected $mfaSession;
+
+ /**
+ * EnforceMfaRequirements constructor.
+ */
+ public function __construct(MfaSession $mfaSession)
+ {
+ $this->mfaSession = $mfaSession;
+ }
+
/**
* Handle an incoming request.
*
@@ -15,10 +26,23 @@ class EnforceMfaRequirements
*/
public function handle($request, Closure $next)
{
- $mfaRequired = user()->roles()->where('mfa_enforced', '=', true)->exists();
- // TODO - Run this after auth (If authenticated)
- // TODO - Redirect user to setup MFA or verify via MFA.
+ if (
+ !$this->mfaSession->isVerified()
+ && !$request->is('mfa/verify*', 'uploads/images/user/*')
+ && $this->mfaSession->requiredForCurrentUser()
+ ) {
+ return redirect('/mfa/verify');
+ }
+
+ // TODO - URI wildcard exceptions above allow access to the 404 page of this user
+ // which could then expose content. Either need to lock that down (Tricky to do image thing)
+ // or prevent any level of auth until verified.
+
+ // TODO - Need to redirect to setup if not configured AND ONLY IF NO OPTIONS CONFIGURED
+ // Might need to change up such routes to start with /configure/ for such identification.
+ // (Can't allow access to those if already configured)
// TODO - Store mfa_pass into session for future requests?
+
return $next($request);
}
}
diff --git a/resources/views/mfa/verify.blade.php b/resources/views/mfa/verify.blade.php
new file mode 100644
index 000000000..4ff0e6c49
--- /dev/null
+++ b/resources/views/mfa/verify.blade.php
@@ -0,0 +1,31 @@
+@extends('simple-layout')
+
+@section('body')
+
+
+
+
Verify Access
+
+ Your user account requires you to confirm your identity via an additional level
+ of verification before you're granted access.
+ Verify using one of your configure methods to continue.
+
+@stop
diff --git a/routes/web.php b/routes/web.php
index e3329edc4..406cfd767 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -4,7 +4,7 @@ Route::get('/status', 'StatusController@show');
Route::get('/robots.txt', 'HomeController@getRobots');
// Authenticated routes...
-Route::group(['middleware' => 'auth'], function () {
+Route::group(['middleware' => ['auth', 'mfa']], function () {
// Secure images routing
Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
@@ -224,13 +224,14 @@ Route::group(['middleware' => 'auth'], function () {
Route::put('/roles/{id}', 'RoleController@update');
});
- // MFA Setup Routes
+ // MFA Routes
Route::get('/mfa/setup', 'Auth\MfaController@setup');
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');
Route::delete('/mfa/remove/{method}', 'Auth\MfaController@remove');
+ Route::get('/mfa/verify', 'Auth\MfaController@verify');
});
// Social auth routes
From 9249addb5c458d30a5b06ff1ccc3a4e1b5d29acb Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Sat, 17 Jul 2021 17:45:00 +0100
Subject: [PATCH 12/25] Updated all login events to route through single
service
---
.../Guards/ExternalBaseSessionGuard.php | 8 +--
app/Auth/Access/LoginService.php | 50 +++++++++++++++++++
app/Auth/Access/Saml2Service.php | 13 ++---
app/Auth/Access/SocialAuthService.php | 17 +++----
.../Auth/ConfirmEmailController.php | 13 +++--
app/Http/Controllers/Auth/LoginController.php | 33 ++++++------
.../Controllers/Auth/RegisterController.php | 16 +++---
.../Controllers/Auth/SocialController.php | 17 ++++---
.../Controllers/Auth/UserInviteController.php | 9 ++--
.../Middleware/EnforceMfaRequirements.php | 2 +-
app/Providers/AppServiceProvider.php | 3 +-
11 files changed, 118 insertions(+), 63 deletions(-)
create mode 100644 app/Auth/Access/LoginService.php
diff --git a/app/Auth/Access/Guards/ExternalBaseSessionGuard.php b/app/Auth/Access/Guards/ExternalBaseSessionGuard.php
index 9c3b47e97..99bfd2e79 100644
--- a/app/Auth/Access/Guards/ExternalBaseSessionGuard.php
+++ b/app/Auth/Access/Guards/ExternalBaseSessionGuard.php
@@ -186,12 +186,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
*/
public function loginUsingId($id, $remember = false)
{
- if (!is_null($user = $this->provider->retrieveById($id))) {
- $this->login($user, $remember);
-
- return $user;
- }
-
+ // Always return false as to disable this method,
+ // Logins should route through LoginService.
return false;
}
diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php
new file mode 100644
index 000000000..2bdef34a2
--- /dev/null
+++ b/app/Auth/Access/LoginService.php
@@ -0,0 +1,50 @@
+login($user);
+ Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
+ Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
+
+ // Authenticate on all session guards if a likely admin
+ if ($user->can('users-manage') && $user->can('user-roles-manage')) {
+ $guards = ['standard', 'ldap', 'saml2'];
+ foreach ($guards as $guard) {
+ auth($guard)->login($user);
+ }
+ }
+ }
+
+
+ /**
+ * Attempt the login of a user using the given credentials.
+ * Meant to mirror laravel's default guard 'attempt' method
+ * but in a manner that always routes through our login system.
+ */
+ public function attempt(array $credentials, string $method, bool $remember = false): bool
+ {
+ $result = auth()->attempt($credentials, $remember);
+ if ($result) {
+ $user = auth()->user();
+ auth()->logout();
+ $this->login($user, $method);
+ }
+
+ return $result;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Auth/Access/Saml2Service.php b/app/Auth/Access/Saml2Service.php
index 28d4d4030..d0b6a655b 100644
--- a/app/Auth/Access/Saml2Service.php
+++ b/app/Auth/Access/Saml2Service.php
@@ -25,16 +25,16 @@ class Saml2Service extends ExternalAuthService
{
protected $config;
protected $registrationService;
- protected $user;
+ protected $loginService;
/**
* Saml2Service constructor.
*/
- public function __construct(RegistrationService $registrationService, User $user)
+ public function __construct(RegistrationService $registrationService, LoginService $loginService)
{
$this->config = config('saml2');
$this->registrationService = $registrationService;
- $this->user = $user;
+ $this->loginService = $loginService;
}
/**
@@ -332,7 +332,7 @@ class Saml2Service extends ExternalAuthService
*/
protected function getOrRegisterUser(array $userDetails): ?User
{
- $user = $this->user->newQuery()
+ $user = User::query()
->where('external_auth_id', '=', $userDetails['external_id'])
->first();
@@ -389,10 +389,7 @@ class Saml2Service extends ExternalAuthService
$this->syncWithGroups($user, $groups);
}
- auth()->login($user);
- Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, 'saml2', $user);
-
+ $this->loginService->login($user, 'saml2');
return $user;
}
}
diff --git a/app/Auth/Access/SocialAuthService.php b/app/Auth/Access/SocialAuthService.php
index 2f1a6876a..70f3fc3d3 100644
--- a/app/Auth/Access/SocialAuthService.php
+++ b/app/Auth/Access/SocialAuthService.php
@@ -2,15 +2,11 @@
namespace BookStack\Auth\Access;
-use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Activity;
-use BookStack\Facades\Theme;
-use BookStack\Theming\ThemeEvents;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
@@ -28,6 +24,11 @@ class SocialAuthService
*/
protected $socialite;
+ /**
+ * @var LoginService
+ */
+ protected $loginService;
+
/**
* The default built-in social drivers we support.
*
@@ -59,9 +60,10 @@ class SocialAuthService
/**
* SocialAuthService constructor.
*/
- public function __construct(Socialite $socialite)
+ public function __construct(Socialite $socialite, LoginService $loginService)
{
$this->socialite = $socialite;
+ $this->loginService = $loginService;
}
/**
@@ -139,10 +141,7 @@ class SocialAuthService
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
if (!$isLoggedIn && $socialAccount !== null) {
- auth()->login($socialAccount->user);
- Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $socialAccount->user);
-
+ $this->loginService->login($socialAccount->user, $socialAccount);
return redirect()->intended('/');
}
diff --git a/app/Http/Controllers/Auth/ConfirmEmailController.php b/app/Http/Controllers/Auth/ConfirmEmailController.php
index 63f220a68..90e5fb76d 100644
--- a/app/Http/Controllers/Auth/ConfirmEmailController.php
+++ b/app/Http/Controllers/Auth/ConfirmEmailController.php
@@ -4,6 +4,7 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\EmailConfirmationService;
+use BookStack\Auth\Access\LoginService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserTokenExpiredException;
@@ -20,14 +21,20 @@ use Illuminate\View\View;
class ConfirmEmailController extends Controller
{
protected $emailConfirmationService;
+ protected $loginService;
protected $userRepo;
/**
* Create a new controller instance.
*/
- public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
+ public function __construct(
+ EmailConfirmationService $emailConfirmationService,
+ LoginService $loginService,
+ UserRepo $userRepo
+ )
{
$this->emailConfirmationService = $emailConfirmationService;
+ $this->loginService = $loginService;
$this->userRepo = $userRepo;
}
@@ -87,9 +94,7 @@ class ConfirmEmailController extends Controller
$user->email_confirmed = true;
$user->save();
- auth()->login($user);
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
- $this->logActivity(ActivityType::AUTH_LOGIN, $user);
+ $this->loginService->login($user, auth()->getDefaultDriver());
$this->showSuccessNotification(trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteByUser($user);
diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php
index 5154f7e97..174770bf9 100644
--- a/app/Http/Controllers/Auth/LoginController.php
+++ b/app/Http/Controllers/Auth/LoginController.php
@@ -3,13 +3,11 @@
namespace BookStack\Http\Controllers\Auth;
use Activity;
-use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
-use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -37,16 +35,19 @@ class LoginController extends Controller
protected $redirectAfterLogout = '/login';
protected $socialAuthService;
+ protected $loginService;
/**
* Create a new controller instance.
*/
- public function __construct(SocialAuthService $socialAuthService)
+ public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
{
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
$this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
$this->socialAuthService = $socialAuthService;
+ $this->loginService = $loginService;
+
$this->redirectPath = url('/');
$this->redirectAfterLogout = url('/login');
}
@@ -140,6 +141,19 @@ class LoginController extends Controller
return $this->sendFailedLoginResponse($request);
}
+ /**
+ * Attempt to log the user into the application.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @return bool
+ */
+ protected function attemptLogin(Request $request)
+ {
+ return $this->loginService->attempt(
+ $this->credentials($request), auth()->getDefaultDriver(), $request->filled('remember')
+ );
+ }
+
/**
* The user has been authenticated.
*
@@ -150,17 +164,6 @@ class LoginController extends Controller
*/
protected function authenticated(Request $request, $user)
{
- // Authenticate on all session guards if a likely admin
- if ($user->can('users-manage') && $user->can('user-roles-manage')) {
- $guards = ['standard', 'ldap', 'saml2'];
- foreach ($guards as $guard) {
- auth($guard)->login($user);
- }
- }
-
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
- $this->logActivity(ActivityType::AUTH_LOGIN, $user);
-
return redirect()->intended($this->redirectPath());
}
diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php
index 1728ece32..4008439bc 100644
--- a/app/Http/Controllers/Auth/RegisterController.php
+++ b/app/Http/Controllers/Auth/RegisterController.php
@@ -2,14 +2,12 @@
namespace BookStack\Http\Controllers\Auth;
-use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\User;
use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -32,6 +30,7 @@ class RegisterController extends Controller
protected $socialAuthService;
protected $registrationService;
+ protected $loginService;
/**
* Where to redirect users after login / registration.
@@ -44,13 +43,18 @@ class RegisterController extends Controller
/**
* Create a new controller instance.
*/
- public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
+ public function __construct(
+ SocialAuthService $socialAuthService,
+ RegistrationService $registrationService,
+ LoginService $loginService
+ )
{
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
+ $this->loginService = $loginService;
$this->redirectTo = url('/');
$this->redirectPath = url('/');
@@ -98,9 +102,7 @@ class RegisterController extends Controller
try {
$user = $this->registrationService->registerUser($userData);
- auth()->login($user);
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
- $this->logActivity(ActivityType::AUTH_LOGIN, $user);
+ $this->loginService->login($user, auth()->getDefaultDriver());
} catch (UserRegistrationException $exception) {
if ($exception->getMessage()) {
$this->showErrorNotification($exception->getMessage());
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index 1d1468698..dd567fe18 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -2,16 +2,14 @@
namespace BookStack\Http\Controllers\Auth;
-use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialUser;
@@ -20,15 +18,21 @@ class SocialController extends Controller
{
protected $socialAuthService;
protected $registrationService;
+ protected $loginService;
/**
* SocialController constructor.
*/
- public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
+ public function __construct(
+ SocialAuthService $socialAuthService,
+ RegistrationService $registrationService,
+ LoginService $loginService
+ )
{
$this->middleware('guest')->only(['getRegister', 'postRegister']);
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
+ $this->loginService = $loginService;
}
/**
@@ -136,12 +140,9 @@ class SocialController extends Controller
}
$user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
- auth()->login($user);
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $user);
- $this->logActivity(ActivityType::AUTH_LOGIN, $user);
+ $this->loginService->login($user, $socialDriver);
$this->showSuccessNotification(trans('auth.register_success'));
-
return redirect('/');
}
}
diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php
index 253e25507..1cc59d6ba 100644
--- a/app/Http/Controllers/Auth/UserInviteController.php
+++ b/app/Http/Controllers/Auth/UserInviteController.php
@@ -3,6 +3,7 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException;
@@ -18,17 +19,19 @@ use Illuminate\Routing\Redirector;
class UserInviteController extends Controller
{
protected $inviteService;
+ protected $loginService;
protected $userRepo;
/**
* Create a new controller instance.
*/
- public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
+ public function __construct(UserInviteService $inviteService, LoginService $loginService, UserRepo $userRepo)
{
$this->middleware('guest');
$this->middleware('guard:standard');
$this->inviteService = $inviteService;
+ $this->loginService = $loginService;
$this->userRepo = $userRepo;
}
@@ -72,9 +75,7 @@ class UserInviteController extends Controller
$user->email_confirmed = true;
$user->save();
- auth()->login($user);
- Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
- $this->logActivity(ActivityType::AUTH_LOGIN, $user);
+ $this->loginService->login($user, auth()->getDefaultDriver());
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);
diff --git a/app/Http/Middleware/EnforceMfaRequirements.php b/app/Http/Middleware/EnforceMfaRequirements.php
index ac3c9609b..1877e5672 100644
--- a/app/Http/Middleware/EnforceMfaRequirements.php
+++ b/app/Http/Middleware/EnforceMfaRequirements.php
@@ -31,7 +31,7 @@ class EnforceMfaRequirements
&& !$request->is('mfa/verify*', 'uploads/images/user/*')
&& $this->mfaSession->requiredForCurrentUser()
) {
- return redirect('/mfa/verify');
+// return redirect('/mfa/verify');
}
// TODO - URI wildcard exceptions above allow access to the 404 page of this user
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index abee6bcda..f7c8fe492 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,6 +3,7 @@
namespace BookStack\Providers;
use Blade;
+use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Entities\BreadcrumbsViewComposer;
use BookStack\Entities\Models\Book;
@@ -68,7 +69,7 @@ class AppServiceProvider extends ServiceProvider
});
$this->app->singleton(SocialAuthService::class, function ($app) {
- return new SocialAuthService($app->make(SocialiteFactory::class));
+ return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
});
}
}
From 1278fb4969f87d2433a6e1e1f70d63f0e9a41d30 Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Sat, 17 Jul 2021 18:24:50 +0100
Subject: [PATCH 13/25] Started moving MFA and email confirmation to new login
flow
Instead of being soley middleware based.
---
app/Auth/Access/LoginService.php | 61 ++++++++++++++++++-
app/Auth/Access/Mfa/MfaSession.php | 34 +++++++----
.../Auth/ConfirmEmailController.php | 7 +--
.../Controllers/Auth/UserInviteController.php | 7 +--
app/Http/Kernel.php | 1 -
app/Http/Middleware/Authenticate.php | 6 --
.../Middleware/ChecksForEmailConfirmation.php | 36 -----------
.../Middleware/EnforceMfaRequirements.php | 48 ---------------
routes/web.php | 2 +-
9 files changed, 84 insertions(+), 118 deletions(-)
delete mode 100644 app/Http/Middleware/ChecksForEmailConfirmation.php
delete mode 100644 app/Http/Middleware/EnforceMfaRequirements.php
diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php
index 2bdef34a2..3d67b1e77 100644
--- a/app/Auth/Access/LoginService.php
+++ b/app/Auth/Access/LoginService.php
@@ -3,6 +3,7 @@
namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
@@ -11,11 +12,47 @@ use BookStack\Theming\ThemeEvents;
class LoginService
{
+ protected $mfaSession;
+
+ public function __construct(MfaSession $mfaSession)
+ {
+ $this->mfaSession = $mfaSession;
+ }
+
+
/**
* Log the given user into the system.
+ * Will start a login of the given user but will prevent if there's
+ * a reason to (MFA or Unconfirmed Email).
+ * Returns a boolean to indicate the current login result.
*/
- public function login(User $user, string $method): void
+ public function login(User $user, string $method): bool
{
+ if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
+ // TODO - Remember who last attempted a login so we can use them after such
+ // a email confirmation or mfa verification step.
+ // Create a method to fetch that attempted user for use by the email confirmation
+ // or MFA verification services.
+ // Also will need a method to 'reattemptLastAttempted' login for once
+ // the email confirmation of MFA verification steps have passed.
+ // Must ensure this remembered last attempted login is cleared upon successful login.
+
+ // TODO - Does 'remember' still work? Probably not right now.
+
+ // Old MFA middleware todos:
+
+ // TODO - Need to redirect to setup if not configured AND ONLY IF NO OPTIONS CONFIGURED
+ // Might need to change up such routes to start with /configure/ for such identification.
+ // (Can't allow access to those if already configured)
+ // TODO - Store mfa_pass into session for future requests?
+
+ // TODO - Handle email confirmation handling
+ // Left BookStack\Http\Middleware\Authenticate@emailConfirmationErrorResponse in which needs
+ // be removed as an example of old behaviour.
+
+ return false;
+ }
+
auth()->login($user);
Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
@@ -27,12 +64,30 @@ class LoginService
auth($guard)->login($user);
}
}
+
+ return true;
}
+ /**
+ * Check if MFA verification is needed.
+ */
+ protected function needsMfaVerification(User $user): bool
+ {
+ return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
+ }
+
+ /**
+ * Check if the given user is awaiting email confirmation.
+ */
+ protected function awaitingEmailConfirmation(User $user): bool
+ {
+ $requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict'));
+ return $requireConfirmation && !$user->email_confirmed;
+ }
/**
* Attempt the login of a user using the given credentials.
- * Meant to mirror laravel's default guard 'attempt' method
+ * Meant to mirror Laravel's default guard 'attempt' method
* but in a manner that always routes through our login system.
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
@@ -41,7 +96,7 @@ class LoginService
if ($result) {
$user = auth()->user();
auth()->logout();
- $this->login($user, $method);
+ $result = $this->login($user, $method);
}
return $result;
diff --git a/app/Auth/Access/Mfa/MfaSession.php b/app/Auth/Access/Mfa/MfaSession.php
index 67574cbaf..dabd568f7 100644
--- a/app/Auth/Access/Mfa/MfaSession.php
+++ b/app/Auth/Access/Mfa/MfaSession.php
@@ -2,43 +2,51 @@
namespace BookStack\Auth\Access\Mfa;
+use BookStack\Auth\User;
+
class MfaSession
{
- private const MFA_VERIFIED_SESSION_KEY = 'mfa-verification-passed';
-
/**
- * Check if MFA is required for the current user.
+ * Check if MFA is required for the given user.
*/
- public function requiredForCurrentUser(): bool
+ public function isRequiredForUser(User $user): bool
{
// TODO - Test both these cases
- return user()->mfaValues()->exists() || $this->currentUserRoleEnforcesMfa();
+ return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
}
/**
- * Check if a role of the current user enforces MFA.
+ * Check if a role of the given user enforces MFA.
*/
- protected function currentUserRoleEnforcesMfa(): bool
+ protected function userRoleEnforcesMfa(User $user): bool
{
- return user()->roles()
+ return $user->roles()
->where('mfa_enforced', '=', true)
->exists();
}
/**
- * Check if the current MFA session has already been verified.
+ * Check if the current MFA session has already been verified for the given user.
*/
- public function isVerified(): bool
+ public function isVerifiedForUser(User $user): bool
{
- return session()->get(self::MFA_VERIFIED_SESSION_KEY) === 'true';
+ return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true';
}
/**
* Mark the current session as MFA-verified.
*/
- public function markVerified(): void
+ public function markVerifiedForUser(User $user): void
{
- session()->put(self::MFA_VERIFIED_SESSION_KEY, 'true');
+ session()->put($this->getMfaVerifiedSessionKey($user), 'true');
+ }
+
+ /**
+ * Get the session key in which the MFA verification status is stored.
+ */
+ protected function getMfaVerifiedSessionKey(User $user): string
+ {
+ return 'mfa-verification-passed:' . $user->id;
}
}
\ No newline at end of file
diff --git a/app/Http/Controllers/Auth/ConfirmEmailController.php b/app/Http/Controllers/Auth/ConfirmEmailController.php
index 90e5fb76d..c4280448e 100644
--- a/app/Http/Controllers/Auth/ConfirmEmailController.php
+++ b/app/Http/Controllers/Auth/ConfirmEmailController.php
@@ -2,16 +2,13 @@
namespace BookStack\Http\Controllers\Auth;
-use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\EmailConfirmationService;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
-use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -94,9 +91,9 @@ class ConfirmEmailController extends Controller
$user->email_confirmed = true;
$user->save();
- $this->loginService->login($user, auth()->getDefaultDriver());
- $this->showSuccessNotification(trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteByUser($user);
+ $this->showSuccessNotification(trans('auth.email_confirm_success'));
+ $this->loginService->login($user, auth()->getDefaultDriver());
return redirect('/');
}
diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php
index 1cc59d6ba..bd1912b0b 100644
--- a/app/Http/Controllers/Auth/UserInviteController.php
+++ b/app/Http/Controllers/Auth/UserInviteController.php
@@ -2,15 +2,12 @@
namespace BookStack\Http\Controllers\Auth;
-use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
-use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -75,9 +72,9 @@ class UserInviteController extends Controller
$user->email_confirmed = true;
$user->save();
- $this->loginService->login($user, auth()->getDefaultDriver());
- $this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);
+ $this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
+ $this->loginService->login($user, auth()->getDefaultDriver());
return redirect('/');
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index c9e59ed3e..4f9bfc1e6 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -48,7 +48,6 @@ class Kernel extends HttpKernel
*/
protected $routeMiddleware = [
'auth' => \BookStack\Http\Middleware\Authenticate::class,
- 'mfa' => \BookStack\Http\Middleware\EnforceMfaRequirements::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php
index 3b018cde0..c687c75a2 100644
--- a/app/Http/Middleware/Authenticate.php
+++ b/app/Http/Middleware/Authenticate.php
@@ -7,17 +7,11 @@ use Illuminate\Http\Request;
class Authenticate
{
- use ChecksForEmailConfirmation;
-
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
- if ($this->awaitingEmailConfirmation()) {
- return $this->emailConfirmationErrorResponse($request);
- }
-
if (!hasAppAccess()) {
if ($request->ajax()) {
return response('Unauthorized.', 401);
diff --git a/app/Http/Middleware/ChecksForEmailConfirmation.php b/app/Http/Middleware/ChecksForEmailConfirmation.php
deleted file mode 100644
index 2eabeca16..000000000
--- a/app/Http/Middleware/ChecksForEmailConfirmation.php
+++ /dev/null
@@ -1,36 +0,0 @@
-awaitingEmailConfirmation()) {
- throw new UnauthorizedException(trans('errors.email_confirmation_awaiting'));
- }
- }
-
- /**
- * Check if email confirmation is required and the current user is awaiting confirmation.
- */
- protected function awaitingEmailConfirmation(): bool
- {
- if (auth()->check()) {
- $requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict'));
- if ($requireConfirmation && !auth()->user()->email_confirmed) {
- return true;
- }
- }
-
- return false;
- }
-}
diff --git a/app/Http/Middleware/EnforceMfaRequirements.php b/app/Http/Middleware/EnforceMfaRequirements.php
deleted file mode 100644
index 1877e5672..000000000
--- a/app/Http/Middleware/EnforceMfaRequirements.php
+++ /dev/null
@@ -1,48 +0,0 @@
-mfaSession = $mfaSession;
- }
-
- /**
- * Handle an incoming request.
- *
- * @param \Illuminate\Http\Request $request
- * @param \Closure $next
- * @return mixed
- */
- public function handle($request, Closure $next)
- {
- if (
- !$this->mfaSession->isVerified()
- && !$request->is('mfa/verify*', 'uploads/images/user/*')
- && $this->mfaSession->requiredForCurrentUser()
- ) {
-// return redirect('/mfa/verify');
- }
-
- // TODO - URI wildcard exceptions above allow access to the 404 page of this user
- // which could then expose content. Either need to lock that down (Tricky to do image thing)
- // or prevent any level of auth until verified.
-
- // TODO - Need to redirect to setup if not configured AND ONLY IF NO OPTIONS CONFIGURED
- // Might need to change up such routes to start with /configure/ for such identification.
- // (Can't allow access to those if already configured)
- // TODO - Store mfa_pass into session for future requests?
-
- return $next($request);
- }
-}
diff --git a/routes/web.php b/routes/web.php
index 406cfd767..a73287762 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -4,7 +4,7 @@ Route::get('/status', 'StatusController@show');
Route::get('/robots.txt', 'HomeController@getRobots');
// Authenticated routes...
-Route::group(['middleware' => ['auth', 'mfa']], function () {
+Route::group(['middleware' => 'auth'], function () {
// Secure images routing
Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
From 1af5bbf3f7404ef9380477657ac1b5df0df119aa Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Sun, 18 Jul 2021 16:52:31 +0100
Subject: [PATCH 14/25] Added login redirect system to confirm/mfa
Also continued a bit on the MFA verification system.
Moved some MFA routes to public space using updated login service to get
the current user that is either logged in or last attempted login (With
correct creds).
---
app/Auth/Access/LoginService.php | 79 +++++++++++++++----
.../StoppedAuthenticationException.php | 40 ++++++++++
.../Auth/ConfirmEmailController.php | 5 +-
.../Controllers/Auth/HandlesPartialLogins.php | 22 ++++++
.../Auth/MfaBackupCodesController.php | 4 +-
app/Http/Controllers/Auth/MfaController.php | 22 +++++-
.../Controllers/Auth/MfaTotpController.php | 6 +-
.../Controllers/Auth/SocialController.php | 2 +-
.../views/auth/user-unconfirmed.blade.php | 4 +-
resources/views/mfa/verify.blade.php | 23 +++++-
routes/web.php | 16 ++--
11 files changed, 186 insertions(+), 37 deletions(-)
create mode 100644 app/Exceptions/StoppedAuthenticationException.php
create mode 100644 app/Http/Controllers/Auth/HandlesPartialLogins.php
diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php
index 3d67b1e77..86126c3b6 100644
--- a/app/Auth/Access/LoginService.php
+++ b/app/Auth/Access/LoginService.php
@@ -5,13 +5,17 @@ namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User;
+use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
+use Exception;
class LoginService
{
+ protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
+
protected $mfaSession;
public function __construct(MfaSession $mfaSession)
@@ -19,24 +23,18 @@ class LoginService
$this->mfaSession = $mfaSession;
}
-
/**
* Log the given user into the system.
* Will start a login of the given user but will prevent if there's
* a reason to (MFA or Unconfirmed Email).
* Returns a boolean to indicate the current login result.
+ * @throws StoppedAuthenticationException
*/
- public function login(User $user, string $method): bool
+ public function login(User $user, string $method): void
{
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
- // TODO - Remember who last attempted a login so we can use them after such
- // a email confirmation or mfa verification step.
- // Create a method to fetch that attempted user for use by the email confirmation
- // or MFA verification services.
- // Also will need a method to 'reattemptLastAttempted' login for once
- // the email confirmation of MFA verification steps have passed.
- // Must ensure this remembered last attempted login is cleared upon successful login.
-
+ $this->setLastLoginAttemptedForUser($user);
+ throw new StoppedAuthenticationException($user, $this);
// TODO - Does 'remember' still work? Probably not right now.
// Old MFA middleware todos:
@@ -44,15 +42,15 @@ class LoginService
// TODO - Need to redirect to setup if not configured AND ONLY IF NO OPTIONS CONFIGURED
// Might need to change up such routes to start with /configure/ for such identification.
// (Can't allow access to those if already configured)
- // TODO - Store mfa_pass into session for future requests?
+ // Or, More likely, Need to add defence to those to prevent access unless
+ // logged in or during partial auth.
// TODO - Handle email confirmation handling
// Left BookStack\Http\Middleware\Authenticate@emailConfirmationErrorResponse in which needs
// be removed as an example of old behaviour.
-
- return false;
}
+ $this->clearLastLoginAttempted();
auth()->login($user);
Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
@@ -64,14 +62,58 @@ class LoginService
auth($guard)->login($user);
}
}
+ }
- return true;
+ /**
+ * Reattempt a system login after a previous stopped attempt.
+ * @throws Exception
+ */
+ public function reattemptLoginFor(User $user, string $method)
+ {
+ if ($user->id !== $this->getLastLoginAttemptUser()) {
+ throw new Exception('Login reattempt user does align with current session state');
+ }
+
+ $this->login($user, $method);
+ }
+
+ /**
+ * Get the last user that was attempted to be logged in.
+ * Only exists if the last login attempt had correct credentials
+ * but had been prevented by a secondary factor.
+ */
+ public function getLastLoginAttemptUser(): ?User
+ {
+ $id = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
+ if (!$id) {
+ return null;
+ }
+
+ return User::query()->where('id', '=', $id)->first();
+ }
+
+ /**
+ * Set the last login attempted user.
+ * Must be only used when credentials are correct and a login could be
+ * achieved but a secondary factor has stopped the login.
+ */
+ protected function setLastLoginAttemptedForUser(User $user)
+ {
+ session()->put(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY, $user->id);
+ }
+
+ /**
+ * Clear the last login attempted session value.
+ */
+ protected function clearLastLoginAttempted(): void
+ {
+ session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
}
/**
* Check if MFA verification is needed.
*/
- protected function needsMfaVerification(User $user): bool
+ public function needsMfaVerification(User $user): bool
{
return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
}
@@ -79,7 +121,7 @@ class LoginService
/**
* Check if the given user is awaiting email confirmation.
*/
- protected function awaitingEmailConfirmation(User $user): bool
+ public function awaitingEmailConfirmation(User $user): bool
{
$requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict'));
return $requireConfirmation && !$user->email_confirmed;
@@ -89,6 +131,9 @@ class LoginService
* Attempt the login of a user using the given credentials.
* Meant to mirror Laravel's default guard 'attempt' method
* but in a manner that always routes through our login system.
+ * May interrupt the flow if extra authentication requirements are imposed.
+ *
+ * @throws StoppedAuthenticationException
*/
public function attempt(array $credentials, string $method, bool $remember = false): bool
{
@@ -96,7 +141,7 @@ class LoginService
if ($result) {
$user = auth()->user();
auth()->logout();
- $result = $this->login($user, $method);
+ $this->login($user, $method);
}
return $result;
diff --git a/app/Exceptions/StoppedAuthenticationException.php b/app/Exceptions/StoppedAuthenticationException.php
new file mode 100644
index 000000000..de8898df7
--- /dev/null
+++ b/app/Exceptions/StoppedAuthenticationException.php
@@ -0,0 +1,40 @@
+user = $user;
+ $this->loginService = $loginService;
+ parent::__construct();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function toResponse($request)
+ {
+ $redirect = '/login';
+
+ if ($this->loginService->awaitingEmailConfirmation($this->user)) {
+ $redirect = '/register/confirm/awaiting';
+ } else if ($this->loginService->needsMfaVerification($this->user)) {
+ $redirect = '/mfa/verify';
+ }
+
+ return redirect($redirect);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Auth/ConfirmEmailController.php b/app/Http/Controllers/Auth/ConfirmEmailController.php
index c4280448e..520efdf88 100644
--- a/app/Http/Controllers/Auth/ConfirmEmailController.php
+++ b/app/Http/Controllers/Auth/ConfirmEmailController.php
@@ -47,12 +47,11 @@ class ConfirmEmailController extends Controller
/**
* Shows a notice that a user's email address has not been confirmed,
* Also has the option to re-send the confirmation email.
- *
- * @return View
*/
public function showAwaiting()
{
- return view('auth.user-unconfirmed');
+ $user = $this->loginService->getLastLoginAttemptUser();
+ return view('auth.user-unconfirmed', ['user' => $user]);
}
/**
diff --git a/app/Http/Controllers/Auth/HandlesPartialLogins.php b/app/Http/Controllers/Auth/HandlesPartialLogins.php
new file mode 100644
index 000000000..f9bacb95d
--- /dev/null
+++ b/app/Http/Controllers/Auth/HandlesPartialLogins.php
@@ -0,0 +1,22 @@
+make(LoginService::class);
+ $user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
+
+ if (!$user) {
+ throw new NotFoundException('A user for this action could not be found');
+ }
+
+ return $user;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Auth/MfaBackupCodesController.php b/app/Http/Controllers/Auth/MfaBackupCodesController.php
index ba4b8f581..b89050565 100644
--- a/app/Http/Controllers/Auth/MfaBackupCodesController.php
+++ b/app/Http/Controllers/Auth/MfaBackupCodesController.php
@@ -10,6 +10,8 @@ use Exception;
class MfaBackupCodesController extends Controller
{
+ use HandlesPartialLogins;
+
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes';
/**
@@ -39,7 +41,7 @@ class MfaBackupCodesController extends Controller
}
$codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY));
- MfaValue::upsertWithValue(user(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
+ MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes');
return redirect('/mfa/setup');
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
index 39a4e852f..6d868b6f3 100644
--- a/app/Http/Controllers/Auth/MfaController.php
+++ b/app/Http/Controllers/Auth/MfaController.php
@@ -5,15 +5,19 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Http\Controllers\Controller;
+use BookStack\Http\Request;
class MfaController extends Controller
{
+ use HandlesPartialLogins;
+
/**
* Show the view to setup MFA for the current user.
*/
public function setup()
{
- $userMethods = user()->mfaValues()
+ $userMethods = $this->currentOrLastAttemptedUser()
+ ->mfaValues()
->get(['id', 'method'])
->groupBy('method');
return view('mfa.setup', [
@@ -41,14 +45,26 @@ class MfaController extends Controller
/**
* Show the page to start an MFA verification.
*/
- public function verify()
+ public function verify(Request $request)
{
- $userMethods = user()->mfaValues()
+ // TODO - Test this
+ $desiredMethod = $request->get('method');
+ $userMethods = $this->currentOrLastAttemptedUser()
+ ->mfaValues()
->get(['id', 'method'])
->groupBy('method');
+ // Basic search for the default option for a user.
+ // (Prioritises totp over backup codes)
+ $method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
+ $otherMethods = $userMethods->keys()->filter(function($userMethod) use ($method) {
+ return $method !== $userMethod;
+ })->all();
+
return view('mfa.verify', [
'userMethods' => $userMethods,
+ 'method' => $method,
+ 'otherMethods' => $otherMethods,
]);
}
}
diff --git a/app/Http/Controllers/Auth/MfaTotpController.php b/app/Http/Controllers/Auth/MfaTotpController.php
index 18f08e709..828af6b03 100644
--- a/app/Http/Controllers/Auth/MfaTotpController.php
+++ b/app/Http/Controllers/Auth/MfaTotpController.php
@@ -6,12 +6,15 @@ use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Auth\Access\Mfa\TotpService;
use BookStack\Auth\Access\Mfa\TotpValidationRule;
+use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class MfaTotpController extends Controller
{
+ use HandlesPartialLogins;
+
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
/**
@@ -39,6 +42,7 @@ class MfaTotpController extends Controller
* Confirm the setup of TOTP and save the auth method secret
* against the current user.
* @throws ValidationException
+ * @throws NotFoundException
*/
public function confirm(Request $request)
{
@@ -51,7 +55,7 @@ class MfaTotpController extends Controller
]
]);
- MfaValue::upsertWithValue(user(), MfaValue::METHOD_TOTP, $totpSecret);
+ MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_TOTP, $totpSecret);
session()->remove(static::SETUP_SECRET_SESSION_KEY);
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp');
diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php
index dd567fe18..2e9e62162 100644
--- a/app/Http/Controllers/Auth/SocialController.php
+++ b/app/Http/Controllers/Auth/SocialController.php
@@ -140,9 +140,9 @@ class SocialController extends Controller
}
$user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
+ $this->showSuccessNotification(trans('auth.register_success'));
$this->loginService->login($user, $socialDriver);
- $this->showSuccessNotification(trans('auth.register_success'));
return redirect('/');
}
}
diff --git a/resources/views/auth/user-unconfirmed.blade.php b/resources/views/auth/user-unconfirmed.blade.php
index 85473685b..5f1edd2b9 100644
--- a/resources/views/auth/user-unconfirmed.blade.php
+++ b/resources/views/auth/user-unconfirmed.blade.php
@@ -17,8 +17,8 @@
{!! csrf_field() !!}
Your user account requires you to confirm your identity via an additional level
of verification before you're granted access.
- Verify using one of your configure methods to continue.
+ Verify using one of your configured methods to continue.
+ @if(!$method)
+
+
No Methods Configured
+
+ No multi-factor authentication methods could be found for your account.
+ You'll need to set up at least one method before you gain access.
+
diff --git a/resources/views/mfa/totp-generate.blade.php b/resources/views/mfa/totp-generate.blade.php
index c1e7547d5..2ed2677d1 100644
--- a/resources/views/mfa/totp-generate.blade.php
+++ b/resources/views/mfa/totp-generate.blade.php
@@ -24,7 +24,7 @@
Verify that all is working by entering a code, generated within your
authentication app, in the input box below:
-