I know it's bad form to submit a large PR like this but this fixes almost everything in https://github.com/firefly-iii/firefly-iii/issues/9183 and I was too lazy to create a branch for it.

This commit is contained in:
James Cole 2024-10-08 07:21:23 +02:00
parent 5597327448
commit 1e472ee095
No known key found for this signature in database
GPG Key ID: B49A324B7EAD6D80
38 changed files with 2261 additions and 548 deletions

View File

@ -0,0 +1,43 @@
<?php
/*
* EnabledMFA.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Events\Security;
use FireflyIII\Events\Event;
use FireflyIII\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Queue\SerializesModels;
class DisabledMFA extends Event
{
use SerializesModels;
public User $user;
public function __construct(null|Authenticatable|User $user)
{
if ($user instanceof User) {
$this->user = $user;
}
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* EnabledMFA.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Events\Security;
use FireflyIII\Events\Event;
use FireflyIII\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Queue\SerializesModels;
class EnabledMFA extends Event
{
use SerializesModels;
public User $user;
public function __construct(null|Authenticatable|User $user)
{
if ($user instanceof User) {
$this->user = $user;
}
}
}

View File

@ -0,0 +1,45 @@
<?php
/*
* EnabledMFA.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Events\Security;
use FireflyIII\Events\Event;
use FireflyIII\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Queue\SerializesModels;
class MFABackupFewLeft extends Event
{
use SerializesModels;
public User $user;
public int $count;
public function __construct(null|Authenticatable|User $user, int $count)
{
if ($user instanceof User) {
$this->user = $user;
}
$this->count = $count;
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* EnabledMFA.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Events\Security;
use FireflyIII\Events\Event;
use FireflyIII\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Queue\SerializesModels;
class MFABackupNoLeft extends Event
{
use SerializesModels;
public User $user;
public function __construct(null|Authenticatable|User $user)
{
if ($user instanceof User) {
$this->user = $user;
}
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* EnabledMFA.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Events\Security;
use FireflyIII\Events\Event;
use FireflyIII\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Queue\SerializesModels;
class MFANewBackupCodes extends Event
{
use SerializesModels;
public User $user;
public function __construct(null|Authenticatable|User $user)
{
if ($user instanceof User) {
$this->user = $user;
}
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* EnabledMFA.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Events\Security;
use FireflyIII\Events\Event;
use FireflyIII\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Queue\SerializesModels;
class MFAUsedBackupCode extends Event
{
use SerializesModels;
public User $user;
public function __construct(null|Authenticatable|User $user)
{
if ($user instanceof User) {
$this->user = $user;
}
}
}

View File

@ -0,0 +1,193 @@
<?php
/*
* MFAHandler.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Handlers\Events\Security;
use FireflyIII\Events\Security\DisabledMFA;
use FireflyIII\Events\Security\EnabledMFA;
use FireflyIII\Events\Security\MFABackupFewLeft;
use FireflyIII\Events\Security\MFABackupNoLeft;
use FireflyIII\Events\Security\MFANewBackupCodes;
use FireflyIII\Events\Security\MFAUsedBackupCode;
use FireflyIII\Notifications\Security\DisabledMFANotification;
use FireflyIII\Notifications\Security\EnabledMFANotification;
use FireflyIII\Notifications\Security\MFABackupFewLeftNotification;
use FireflyIII\Notifications\Security\MFABackupNoLeftNotification;
use FireflyIII\Notifications\Security\MFAUsedBackupCodeNotification;
use FireflyIII\Notifications\Security\NewBackupCodesNotification;
use Illuminate\Support\Facades\Notification;
class MFAHandler
{
public function sendMFAEnabledMail(EnabledMFA $event): void
{
app('log')->debug(sprintf('Now in %s', __METHOD__));
$user = $event->user;
try {
Notification::send($user, new EnabledMFANotification($user));
} catch (\Exception $e) { // @phpstan-ignore-line
$message = $e->getMessage();
if (str_contains($message, 'Bcc')) {
app('log')->warning('[Bcc] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
if (str_contains($message, 'RFC 2822')) {
app('log')->warning('[RFC] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
app('log')->error($e->getMessage());
app('log')->error($e->getTraceAsString());
}
}
public function sendNewMFABackupCodesMail(MFANewBackupCodes $event): void
{
app('log')->debug(sprintf('Now in %s', __METHOD__));
$user = $event->user;
try {
Notification::send($user, new NewBackupCodesNotification($user));
} catch (\Exception $e) { // @phpstan-ignore-line
$message = $e->getMessage();
if (str_contains($message, 'Bcc')) {
app('log')->warning('[Bcc] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
if (str_contains($message, 'RFC 2822')) {
app('log')->warning('[RFC] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
app('log')->error($e->getMessage());
app('log')->error($e->getTraceAsString());
}
}
public function sendBackupFewLeftMail(MFABackupFewLeft $event): void
{
app('log')->debug(sprintf('Now in %s', __METHOD__));
$user = $event->user;
$count = $event->count;
try {
Notification::send($user, new MFABackupFewLeftNotification($user, $count));
} catch (\Exception $e) { // @phpstan-ignore-line
$message = $e->getMessage();
if (str_contains($message, 'Bcc')) {
app('log')->warning('[Bcc] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
if (str_contains($message, 'RFC 2822')) {
app('log')->warning('[RFC] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
app('log')->error($e->getMessage());
app('log')->error($e->getTraceAsString());
}
}
public function sendBackupNoLeftMail(MFABackupNoLeft $event): void
{
app('log')->debug(sprintf('Now in %s', __METHOD__));
$user = $event->user;
try {
Notification::send($user, new MFABackupNoLeftNotification($user));
} catch (\Exception $e) { // @phpstan-ignore-line
$message = $e->getMessage();
if (str_contains($message, 'Bcc')) {
app('log')->warning('[Bcc] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
if (str_contains($message, 'RFC 2822')) {
app('log')->warning('[RFC] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
app('log')->error($e->getMessage());
app('log')->error($e->getTraceAsString());
}
}
public function sendUsedBackupCodeMail(MFAUsedBackupCode $event): void
{
app('log')->debug(sprintf('Now in %s', __METHOD__));
$user = $event->user;
try {
Notification::send($user, new MFAUsedBackupCodeNotification($user));
} catch (\Exception $e) { // @phpstan-ignore-line
$message = $e->getMessage();
if (str_contains($message, 'Bcc')) {
app('log')->warning('[Bcc] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
if (str_contains($message, 'RFC 2822')) {
app('log')->warning('[RFC] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
app('log')->error($e->getMessage());
app('log')->error($e->getTraceAsString());
}
}
public function sendMFADisabledMail(DisabledMFA $event): void
{
app('log')->debug(sprintf('Now in %s', __METHOD__));
$user = $event->user;
try {
Notification::send($user, new DisabledMFANotification($user));
} catch (\Exception $e) { // @phpstan-ignore-line
$message = $e->getMessage();
if (str_contains($message, 'Bcc')) {
app('log')->warning('[Bcc] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
if (str_contains($message, 'RFC 2822')) {
app('log')->warning('[RFC] Could not send notification. Please validate your email settings, use the .env.example file as a guide.');
return;
}
app('log')->error($e->getMessage());
app('log')->error($e->getTraceAsString());
}
}
}

View File

@ -23,6 +23,9 @@ declare(strict_types=1);
namespace FireflyIII\Http\Controllers\Auth; namespace FireflyIII\Http\Controllers\Auth;
use FireflyIII\Events\Security\MFABackupFewLeft;
use FireflyIII\Events\Security\MFABackupNoLeft;
use FireflyIII\Events\Security\MFAUsedBackupCode;
use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Controllers\Controller;
use FireflyIII\User; use FireflyIII\User;
use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\Factory;
@ -30,6 +33,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Redirector; use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Log;
use PragmaRX\Google2FALaravel\Support\Authenticator; use PragmaRX\Google2FALaravel\Support\Authenticator;
/** /**
@ -86,6 +90,10 @@ class TwoFactorController extends Controller
$authenticator->login(); $authenticator->login();
session()->flash('info', trans('firefly.mfa_backup_code')); session()->flash('info', trans('firefly.mfa_backup_code'));
// send user notification.
$user = auth()->user();
Log::channel('audit')->info(sprintf('User "%s" has used a backup code.', $user->email));
event(new MFAUsedBackupCode($user));
return redirect(route('home')); return redirect(route('home'));
} }
@ -175,6 +183,20 @@ class TwoFactorController extends Controller
$list = []; $list = [];
} }
$newList = array_values(array_diff($list, [$mfaCode])); $newList = array_values(array_diff($list, [$mfaCode]));
// if the list is 3 or less, send a notification.
if(count($newList) <= 3 && count($newList) > 0) {
$user = auth()->user();
Log::channel('audit')->info(sprintf('User "%s" has used a backup code. They have %d backup codes left.', $user->email, count($newList)));
event(new MFABackupFewLeft($user, count($newList)));
}
// if the list is empty, send notification
if(0 === count($newList)) {
$user = auth()->user();
Log::channel('audit')->info(sprintf('User "%s" has used their last backup code.', $user->email));
event(new MFABackupNoLeft($user));
}
app('preferences')->set('mfa_recovery', $newList); app('preferences')->set('mfa_recovery', $newList);
} }
} }

View File

@ -0,0 +1,340 @@
<?php
/*
* MfaController.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Http\Controllers\Profile;
use FireflyIII\Events\ActuallyLoggedIn;
use FireflyIII\Events\Security\DisabledMFA;
use FireflyIII\Events\Security\EnabledMFA;
use FireflyIII\Events\Security\MFANewBackupCodes;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Http\Controllers\Controller;
use FireflyIII\Http\Middleware\IsDemoUser;
use FireflyIII\Http\Requests\ExistingTokenFormRequest;
use FireflyIII\Http\Requests\TokenFormRequest;
use FireflyIII\Repositories\User\UserRepositoryInterface;
use FireflyIII\User;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
use PragmaRX\Recovery\Recovery;
/**
* Class MfaController
*
* Enable MFA Flow:
*
* Page 1 (GET): Show QR code and the manual code. Secret keeps rotating.
* POST: store secret, store response, validate password.
* ---
* Page 3 (GET): Confirm 2FA status and show recovery codes.
* Same page as page 1, but when secret is present.
*/
class MfaController extends Controller
{
protected bool $internalAuth;
/**
* ProfileController constructor.
*/
public function __construct()
{
parent::__construct();
$this->middleware(
static function ($request, $next) {
app('view')->share('title', (string) trans('firefly.profile'));
app('view')->share('mainTitleIcon', 'fa-user');
return $next($request);
}
);
$authGuard = config('firefly.authentication_guard');
$this->internalAuth = 'web' === $authGuard;
app('log')->debug(sprintf('ProfileController::__construct(). Authentication guard is "%s"', $authGuard));
$this->middleware(IsDemoUser::class)->except(['index']);
}
public function index(): Factory | RedirectResponse | View
{
if (!$this->internalAuth) {
request()->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
$subTitle = (string)trans('firefly.mfa_index_title');
$subTitleIcon = 'fa-calculator';
$enabledMFA = null !== auth()->user()->mfa_secret;
return view('profile.mfa.index')->with(compact('subTitle', 'subTitleIcon','enabledMFA'));
}
public function disableMFA(Request $request): Factory | RedirectResponse | View
{
if (!$this->internalAuth) {
request()->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
$enabledMFA = null !== auth()->user()->mfa_secret;
if(false === $enabledMFA){
request()->session()->flash('info',trans('firefly.mfa_already_disabled'));
return redirect(route('profile.index'));
}
$subTitle = (string)trans('firefly.mfa_index_title');
$subTitleIcon = 'fa-calculator';
return view('profile.mfa.disable-mfa')->with(compact('subTitle', 'subTitleIcon','enabledMFA'));
}
/**
* Delete 2FA routine.
*/
public function disableMFAPost(ExistingTokenFormRequest $request): Redirector | RedirectResponse
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
/** @var UserRepositoryInterface $repository */
$repository = app(UserRepositoryInterface::class);
/** @var User $user */
$user = auth()->user();
app('preferences')->delete('temp-mfa-secret');
app('preferences')->delete('temp-mfa-codes');
$repository->setMFACode($user, null);
app('preferences')->mark();
session()->flash('success', (string) trans('firefly.pref_two_factor_auth_disabled'));
session()->flash('info', (string) trans('firefly.pref_two_factor_auth_remove_it'));
// also logout current 2FA tokens.
$cookieName = config('google2fa.cookie_name', 'google2fa_token');
\Cookie::forget($cookieName);
// send user notification.
Log::channel('audit')->info(sprintf('User "%s" has disabled MFA', $user->email));
event(new DisabledMFA($user));
return redirect(route('profile.index'));
}
/**
* Enable 2FA screen.
*/
public function enableMFA(Request $request): Redirector | RedirectResponse | View
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
/** @var User $user */
$user = auth()->user();
$enabledMFA = null !== $user->mfa_secret;
// If FF3 already has a secret, just set the two-factor auth enabled to 1,
// and let the user continue with the existing secret.
if ($enabledMFA) {
session()->flash('info', (string) trans('firefly.2fa_already_enabled'));
return redirect(route('profile.index'));
}
$domain = $this->getDomain();
$secret = \Google2FA::generateSecretKey();
$image = \Google2FA::getQRCodeInline($domain, auth()->user()->email, (string) $secret);
app('preferences')->set('temp-mfa-secret', $secret);
return view('profile.mfa.enable-mfa', compact('image', 'secret'));
}
public function backupCodesPost(ExistingTokenFormRequest $request): Redirector | RedirectResponse | View {
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
$enabledMFA = null !== auth()->user()->mfa_secret;
if(false === $enabledMFA){
request()->session()->flash('info',trans('firefly.mfa_not_enabled'));
return redirect(route('profile.index'));
}
// generate recovery codes:
$recovery = app(Recovery::class);
$recoveryCodes = $recovery->lowercase()
->setCount(8) // Generate 8 codes
->setBlocks(2) // Every code must have 2 blocks
->setChars(6) // Each block must have 6 chars
->toArray();
$codes = implode("\r\n", $recoveryCodes);
app('preferences')->set('mfa_recovery', $recoveryCodes);
app('preferences')->mark();
// send user notification.
$user = auth()->user();
Log::channel('audit')->info(sprintf('User "%s" has generated new backup codes.', $user->email));
event(new MFANewBackupCodes($user));
return view('profile.mfa.backup-codes-post')->with(compact('codes'));
}
/**
* @throws FireflyException
*/
public function backupCodes(Request $request): Factory | RedirectResponse | View
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
$enabledMFA = null !== auth()->user()->mfa_secret;
if(false === $enabledMFA){
request()->session()->flash('info',trans('firefly.mfa_not_enabled'));
return redirect(route('profile.index'));
}
return view('profile.mfa.backup-codes-intro');
}
/**
* Submit 2FA for the first time.
*
* @return Redirector|RedirectResponse
*
* @throws FireflyException
*/
public function enableMFAPost(TokenFormRequest $request)
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
/** @var User $user */
$user = auth()->user();
// verify password.
$password = $request->get('password');
if (!auth()->validate(['email' => $user->email, 'password' => $password])) {
session()->flash('error', 'Bad user pw, no MFA for you!');
return redirect(route('profile.mfa.index'));
}
/** @var UserRepositoryInterface $repository */
$repository = app(UserRepositoryInterface::class);
$secret = app('preferences')->get('temp-mfa-secret')?->data;
if (is_array($secret)) {
$secret = null;
}
$secret = (string) $secret;
$repository->setMFACode($user, $secret);
app('preferences')->delete('temp-mfa-secret');
session()->flash('success', (string) trans('firefly.saved_preferences'));
app('preferences')->mark();
// also save the code so replay attack is prevented.
$mfaCode = $request->get('code');
$this->addToMFAHistory($mfaCode);
// make sure MFA is logged out.
if ('testing' !== config('app.env')) {
\Google2FA::logout();
}
// drop all info from session:
session()->forget(['temp-mfa-secret', 'two-factor-secret', 'two-factor-codes']);
// send user notification.
Log::channel('audit')->info(sprintf('User "%s" has enabled MFA', $user->email));
event(new EnabledMFA($user));
return redirect(route('profile.mfa.backup-codes'));
}
/**
* TODO duplicate code.
*
* @throws FireflyException
*/
private function addToMFAHistory(string $mfaCode): void
{
/** @var array $mfaHistory */
$mfaHistory = app('preferences')->get('mfa_history', [])->data;
$entry = [
'time' => time(),
'code' => $mfaCode,
];
$mfaHistory[] = $entry;
app('preferences')->set('mfa_history', $mfaHistory);
$this->filterMFAHistory();
}
/**
* Remove old entries from the preferences array.
*/
private function filterMFAHistory(): void
{
/** @var array $mfaHistory */
$mfaHistory = app('preferences')->get('mfa_history', [])->data;
$newHistory = [];
$now = time();
foreach ($mfaHistory as $entry) {
$time = $entry['time'];
$code = $entry['code'];
if ($now - $time <= 300) {
$newHistory[] = [
'time' => $time,
'code' => $code,
];
}
}
app('preferences')->set('mfa_history', $newHistory);
}
}

View File

@ -83,65 +83,6 @@ class ProfileController extends Controller
$this->middleware(IsDemoUser::class)->except(['index']); $this->middleware(IsDemoUser::class)->except(['index']);
} }
/**
* View that generates a 2FA code for the user.
*
* @throws IncompatibleWithGoogleAuthenticatorException
* @throws InvalidCharactersException
* @throws SecretKeyTooShortException
*/
public function code(Request $request): Factory|RedirectResponse|View
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
$domain = $this->getDomain();
$secretPreference = app('preferences')->get('temp-mfa-secret');
$codesPreference = app('preferences')->get('temp-mfa-codes');
// generate secret if not in session
if (null === $secretPreference) {
// generate secret + store + flash
$secret = \Google2FA::generateSecretKey();
app('preferences')->set('temp-mfa-secret', $secret);
}
// re-use secret if in session
if (null !== $secretPreference) {
// get secret from session and flash
$secret = $secretPreference->data;
}
if (is_array($secret)) {
$secret = '';
}
// generate recovery codes if not in session:
$recoveryCodes = '';
if (null === $codesPreference) {
// generate codes + store + flash:
$recovery = app(Recovery::class);
$recoveryCodes = $recovery->lowercase()->setCount(8)->setBlocks(2)->setChars(6)->toArray();
app('preferences')->set('temp-mfa-codes', $recoveryCodes);
}
// get codes from session if present already:
if (null !== $codesPreference) {
$recoveryCodes = $codesPreference->data;
}
if (!is_array($recoveryCodes)) {
$recoveryCodes = [];
}
$codes = implode("\r\n", $recoveryCodes);
$image = \Google2FA::getQRCodeInline($domain, auth()->user()->email, (string)$secret);
return view('profile.code', compact('image', 'secret', 'codes'));
}
/** /**
* Screen to confirm email change. * Screen to confirm email change.
* *
@ -193,61 +134,6 @@ class ProfileController extends Controller
return view('profile.delete-account', compact('title', 'subTitle', 'subTitleIcon')); return view('profile.delete-account', compact('title', 'subTitle', 'subTitleIcon'));
} }
/**
* Delete 2FA routine.
*/
public function deleteCode(Request $request): Redirector|RedirectResponse
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
/** @var UserRepositoryInterface $repository */
$repository = app(UserRepositoryInterface::class);
/** @var User $user */
$user = auth()->user();
app('preferences')->delete('temp-mfa-secret');
app('preferences')->delete('temp-mfa-codes');
$repository->setMFACode($user, null);
app('preferences')->mark();
session()->flash('success', (string)trans('firefly.pref_two_factor_auth_disabled'));
session()->flash('info', (string)trans('firefly.pref_two_factor_auth_remove_it'));
return redirect(route('profile.index'));
}
/**
* Enable 2FA screen.
*/
public function enable2FA(Request $request): Redirector|RedirectResponse
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
/** @var User $user */
$user = auth()->user();
$enabledMFA = null !== $user->mfa_secret;
// if we don't have a valid secret yet, redirect to the code page to get one.
if (!$enabledMFA) {
return redirect(route('profile.code'));
}
// If FF3 already has a secret, just set the two factor auth enabled to 1,
// and let the user continue with the existing secret.
session()->flash('info', (string)trans('firefly.2fa_already_enabled'));
return redirect(route('profile.index'));
}
/** /**
* Index for profile. * Index for profile.
* *
@ -298,33 +184,6 @@ class ProfileController extends Controller
return view('profile.logout-other-sessions'); return view('profile.logout-other-sessions');
} }
/**
* @throws FireflyException
*/
public function newBackupCodes(Request $request): Factory|RedirectResponse|View
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
// generate recovery codes:
$recovery = app(Recovery::class);
$recoveryCodes = $recovery->lowercase()
->setCount(8) // Generate 8 codes
->setBlocks(2) // Every code must have 7 blocks
->setChars(6) // Each block must have 16 chars
->toArray()
;
$codes = implode("\r\n", $recoveryCodes);
app('preferences')->set('mfa_recovery', $recoveryCodes);
app('preferences')->mark();
return view('profile.new-backup-codes', compact('codes'));
}
/** /**
* Submit the change email form. * Submit the change email form.
*/ */
@ -442,99 +301,6 @@ class ProfileController extends Controller
return view('profile.change-password', compact('title', 'subTitle', 'subTitleIcon')); return view('profile.change-password', compact('title', 'subTitle', 'subTitleIcon'));
} }
/**
* Submit 2FA for the first time.
*
* @return Redirector|RedirectResponse
*
* @throws FireflyException
*/
public function postCode(TokenFormRequest $request)
{
if (!$this->internalAuth) {
$request->session()->flash('error', trans('firefly.external_user_mgt_disabled'));
return redirect(route('profile.index'));
}
/** @var User $user */
$user = auth()->user();
/** @var UserRepositoryInterface $repository */
$repository = app(UserRepositoryInterface::class);
$secret = app('preferences')->get('temp-mfa-secret')?->data;
if (is_array($secret)) {
$secret = null;
}
$secret = (string)$secret;
$repository->setMFACode($user, $secret);
app('preferences')->delete('temp-mfa-secret');
app('preferences')->delete('temp-mfa-codes');
session()->flash('success', (string)trans('firefly.saved_preferences'));
app('preferences')->mark();
// also save the code so replay attack is prevented.
$mfaCode = $request->get('code');
$this->addToMFAHistory($mfaCode);
// save backup codes in preferences:
app('preferences')->set('mfa_recovery', session()->get('temp-mfa-codes'));
// make sure MFA is logged out.
if ('testing' !== config('app.env')) {
\Google2FA::logout();
}
// drop all info from session:
session()->forget(['temp-mfa-secret', 'two-factor-secret', 'temp-mfa-codes', 'two-factor-codes']);
return redirect(route('profile.index'));
}
/**
* TODO duplicate code.
*
* @throws FireflyException
*/
private function addToMFAHistory(string $mfaCode): void
{
/** @var array $mfaHistory */
$mfaHistory = app('preferences')->get('mfa_history', [])->data;
$entry = [
'time' => time(),
'code' => $mfaCode,
];
$mfaHistory[] = $entry;
app('preferences')->set('mfa_history', $mfaHistory);
$this->filterMFAHistory();
}
/**
* Remove old entries from the preferences array.
*/
private function filterMFAHistory(): void
{
/** @var array $mfaHistory */
$mfaHistory = app('preferences')->get('mfa_history', [])->data;
$newHistory = [];
$now = time();
foreach ($mfaHistory as $entry) {
$time = $entry['time'];
$code = $entry['code'];
if ($now - $time <= 300) {
$newHistory[] = [
'time' => $time,
'code' => $code,
];
}
}
app('preferences')->set('mfa_history', $newHistory);
}
/** /**
* Submit delete account. * Submit delete account.
* *
@ -664,7 +430,7 @@ class ProfileController extends Controller
$repository->changeEmail($user, $match); $repository->changeEmail($user, $match);
$repository->unblockUser($user); $repository->unblockUser($user);
// return to login. // return to login page.
session()->flash('success', (string)trans('firefly.login_with_old_email')); session()->flash('success', (string)trans('firefly.login_with_old_email'));
return redirect(route('login')); return redirect(route('login'));

View File

@ -0,0 +1,56 @@
<?php
/**
* TokenFormRequest.php
* Copyright (c) 2019 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace FireflyIII\Http\Requests;
use FireflyIII\Support\Request\ChecksLogin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Validator;
/**
* Class ExistingTokenFormRequest.
*/
class ExistingTokenFormRequest extends FormRequest
{
use ChecksLogin;
/**
* Rules for this request.
*/
public function rules(): array
{
// fixed
return [
'password' => 'required|currentPassword',
'code' => 'required|existingMfaCode',
];
}
public function withValidator(Validator $validator): void
{
if ($validator->fails()) {
Log::channel('audit')->error(sprintf('Validation errors in %s', __CLASS__), $validator->errors()->toArray());
}
}
}

View File

@ -42,6 +42,7 @@ class TokenFormRequest extends FormRequest
{ {
// fixed // fixed
return [ return [
'password' => 'required|currentPassword',
'code' => 'required|2faCode', 'code' => 'required|2faCode',
]; ];
} }

View File

@ -0,0 +1,117 @@
<?php
/*
* EnabledMFANotification.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Notifications\Security;
use FireflyIII\Support\Notifications\UrlValidator;
use FireflyIII\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class DisabledMFANotification extends Notification
{
use Queueable;
private User $user;
/**
* Create a new notification instance.
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toArray($notifiable)
{
return [
];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return MailMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toMail($notifiable)
{
$subject = (string)trans('email.disabled_mfa_subject');
return (new MailMessage())->markdown('emails.security.disabled-mfa', ['user' => $this->user])->subject($subject);
}
/**
* Get the Slack representation of the notification.
*
* @param mixed $notifiable
*
* @return SlackMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toSlack($notifiable)
{
$message = (string)trans('email.disabled_mfa_slack', ['email' => $this->user->email]);
return (new SlackMessage())->content($message);
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function via($notifiable)
{
/** @var null|User $user */
$user = auth()->user();
$slackUrl = null === $user ? '' : app('preferences')->getForUser(auth()->user(), 'slack_webhook_url', '')->data;
if (is_array($slackUrl)) {
$slackUrl = '';
}
if (UrlValidator::isValidWebhookURL((string)$slackUrl)) {
return ['mail', 'slack'];
}
return ['mail'];
}
}

View File

@ -0,0 +1,118 @@
<?php
/*
* EnabledMFANotification.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Notifications\Security;
use FireflyIII\Models\Bill;
use FireflyIII\Support\Notifications\UrlValidator;
use FireflyIII\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class EnabledMFANotification extends Notification
{
use Queueable;
private User $user;
/**
* Create a new notification instance.
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toArray($notifiable)
{
return [
];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return MailMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toMail($notifiable)
{
$subject = (string)trans('email.enabled_mfa_subject');
return (new MailMessage())->markdown('emails.security.enabled-mfa', ['user' => $this->user])->subject($subject);
}
/**
* Get the Slack representation of the notification.
*
* @param mixed $notifiable
*
* @return SlackMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toSlack($notifiable)
{
$message = (string)trans('email.enabled_mfa_slack', ['email' => $this->user->email]);
return (new SlackMessage())->content($message);
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function via($notifiable)
{
/** @var null|User $user */
$user = auth()->user();
$slackUrl = null === $user ? '' : app('preferences')->getForUser(auth()->user(), 'slack_webhook_url', '')->data;
if (is_array($slackUrl)) {
$slackUrl = '';
}
if (UrlValidator::isValidWebhookURL((string)$slackUrl)) {
return ['mail', 'slack'];
}
return ['mail'];
}
}

View File

@ -0,0 +1,119 @@
<?php
/*
* EnabledMFANotification.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Notifications\Security;
use FireflyIII\Support\Notifications\UrlValidator;
use FireflyIII\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class MFABackupFewLeftNotification extends Notification
{
use Queueable;
private User $user;
private int $count;
/**
* Create a new notification instance.
*/
public function __construct(User $user, int $count)
{
$this->user = $user;
$this->count = $count;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toArray($notifiable)
{
return [
];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return MailMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toMail($notifiable)
{
$subject = (string)trans('email.mfa_few_backups_left_subject', ['count' => $this->count]);
return (new MailMessage())->markdown('emails.security.few-backup-codes', ['user' => $this->user, 'count' => $this->count])->subject($subject);
}
/**
* Get the Slack representation of the notification.
*
* @param mixed $notifiable
*
* @return SlackMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toSlack($notifiable)
{
$message = (string)trans('email.mfa_few_backups_left_slack', ['email' => $this->user->email, 'count' => $this->count]);
return (new SlackMessage())->content($message);
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function via($notifiable)
{
/** @var null|User $user */
$user = auth()->user();
$slackUrl = null === $user ? '' : app('preferences')->getForUser(auth()->user(), 'slack_webhook_url', '')->data;
if (is_array($slackUrl)) {
$slackUrl = '';
}
if (UrlValidator::isValidWebhookURL((string)$slackUrl)) {
return ['mail', 'slack'];
}
return ['mail'];
}
}

View File

@ -0,0 +1,117 @@
<?php
/*
* EnabledMFANotification.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Notifications\Security;
use FireflyIII\Support\Notifications\UrlValidator;
use FireflyIII\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class MFABackupNoLeftNotification extends Notification
{
use Queueable;
private User $user;
/**
* Create a new notification instance.
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toArray($notifiable)
{
return [
];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return MailMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toMail($notifiable)
{
$subject = (string)trans('email.mfa_no_backups_left_subject');
return (new MailMessage())->markdown('emails.security.no-backup-codes', ['user' => $this->user])->subject($subject);
}
/**
* Get the Slack representation of the notification.
*
* @param mixed $notifiable
*
* @return SlackMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toSlack($notifiable)
{
$message = (string)trans('email.mfa_no_backups_left_slack', ['email' => $this->user->email]);
return (new SlackMessage())->content($message);
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function via($notifiable)
{
/** @var null|User $user */
$user = auth()->user();
$slackUrl = null === $user ? '' : app('preferences')->getForUser(auth()->user(), 'slack_webhook_url', '')->data;
if (is_array($slackUrl)) {
$slackUrl = '';
}
if (UrlValidator::isValidWebhookURL((string)$slackUrl)) {
return ['mail', 'slack'];
}
return ['mail'];
}
}

View File

@ -0,0 +1,117 @@
<?php
/*
* EnabledMFANotification.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Notifications\Security;
use FireflyIII\Support\Notifications\UrlValidator;
use FireflyIII\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class MFAUsedBackupCodeNotification extends Notification
{
use Queueable;
private User $user;
/**
* Create a new notification instance.
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toArray($notifiable)
{
return [
];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return MailMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toMail($notifiable)
{
$subject = (string)trans('email.used_backup_code_subject');
return (new MailMessage())->markdown('emails.security.used-backup-code', ['user' => $this->user])->subject($subject);
}
/**
* Get the Slack representation of the notification.
*
* @param mixed $notifiable
*
* @return SlackMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toSlack($notifiable)
{
$message = (string)trans('email.used_backup_code_slack', ['email' => $this->user->email]);
return (new SlackMessage())->content($message);
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function via($notifiable)
{
/** @var null|User $user */
$user = auth()->user();
$slackUrl = null === $user ? '' : app('preferences')->getForUser(auth()->user(), 'slack_webhook_url', '')->data;
if (is_array($slackUrl)) {
$slackUrl = '';
}
if (UrlValidator::isValidWebhookURL((string)$slackUrl)) {
return ['mail', 'slack'];
}
return ['mail'];
}
}

View File

@ -0,0 +1,117 @@
<?php
/*
* EnabledMFANotification.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Notifications\Security;
use FireflyIII\Support\Notifications\UrlValidator;
use FireflyIII\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class NewBackupCodesNotification extends Notification
{
use Queueable;
private User $user;
/**
* Create a new notification instance.
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toArray($notifiable)
{
return [
];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return MailMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toMail($notifiable)
{
$subject = (string)trans('email.new_backup_codes_subject');
return (new MailMessage())->markdown('emails.security.new-backup-codes', ['user' => $this->user])->subject($subject);
}
/**
* Get the Slack representation of the notification.
*
* @param mixed $notifiable
*
* @return SlackMessage
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function toSlack($notifiable)
{
$message = (string)trans('email.new_backup_codes_slack', ['email' => $this->user->email]);
return (new SlackMessage())->content($message);
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
*
* @return array
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function via($notifiable)
{
/** @var null|User $user */
$user = auth()->user();
$slackUrl = null === $user ? '' : app('preferences')->getForUser(auth()->user(), 'slack_webhook_url', '')->data;
if (is_array($slackUrl)) {
$slackUrl = '';
}
if (UrlValidator::isValidWebhookURL((string)$slackUrl)) {
return ['mail', 'slack'];
}
return ['mail'];
}
}

View File

@ -40,6 +40,12 @@ use FireflyIII\Events\RequestedNewPassword;
use FireflyIII\Events\RequestedReportOnJournals; use FireflyIII\Events\RequestedReportOnJournals;
use FireflyIII\Events\RequestedSendWebhookMessages; use FireflyIII\Events\RequestedSendWebhookMessages;
use FireflyIII\Events\RequestedVersionCheckStatus; use FireflyIII\Events\RequestedVersionCheckStatus;
use FireflyIII\Events\Security\DisabledMFA;
use FireflyIII\Events\Security\EnabledMFA;
use FireflyIII\Events\Security\MFABackupFewLeft;
use FireflyIII\Events\Security\MFABackupNoLeft;
use FireflyIII\Events\Security\MFANewBackupCodes;
use FireflyIII\Events\Security\MFAUsedBackupCode;
use FireflyIII\Events\StoredAccount; use FireflyIII\Events\StoredAccount;
use FireflyIII\Events\StoredTransactionGroup; use FireflyIII\Events\StoredTransactionGroup;
use FireflyIII\Events\TriggeredAuditLog; use FireflyIII\Events\TriggeredAuditLog;
@ -205,6 +211,26 @@ class EventServiceProvider extends ServiceProvider
RuleActionFailedOnObject::class => [ RuleActionFailedOnObject::class => [
'FireflyIII\Handlers\Events\Model\RuleHandler@ruleActionFailedOnObject', 'FireflyIII\Handlers\Events\Model\RuleHandler@ruleActionFailedOnObject',
], ],
// security related
EnabledMFA::class => [
'FireflyIII\Handlers\Events\Security\MFAHandler@sendMFAEnabledMail',
],
DisabledMFA::class => [
'FireflyIII\Handlers\Events\Security\MFAHandler@sendMFADisabledMail',
],
MFANewBackupCodes::class => [
'FireflyIII\Handlers\Events\Security\MFAHandler@sendNewMFABackupCodesMail',
],
MFAUsedBackupCode::class => [
'FireflyIII\Handlers\Events\Security\MFAHandler@sendUsedBackupCodeMail',
],
MFABackupFewLeft::class => [
'FireflyIII\Handlers\Events\Security\MFAHandler@sendBackupFewLeftMail',
],
MFABackupNoLeft::class => [
'FireflyIII\Handlers\Events\Security\MFAHandler@sendBackupNoLeftMail',
],
]; ];
/** /**

View File

@ -73,9 +73,25 @@ class FireflyValidator extends Validator
if (is_array($secret)) { if (is_array($secret)) {
$secret = ''; $secret = '';
} }
$secret = (string) $secret;
return (bool) \Google2FA::verifyKey((string) $secret, $value); return (bool) \Google2FA::verifyKey((string) $secret, $value);
} }
public function validateExistingMfaCode($attribute, $value): bool
{
if (!is_string($value) || 6 !== strlen($value)) {
return false;
}
$user = auth()->user();
if (null === $user) {
app('log')->error('No user during validate2faCode');
return false;
}
$secret = (string)$user->mfa_secret;
return (bool) \Google2FA::verifyKey($secret, $value);
}
/** /**
* @param mixed $attribute * @param mixed $attribute
@ -523,8 +539,7 @@ class FireflyValidator extends Validator
/** @var null|Account $result */ /** @var null|Account $result */
$result = auth()->user()->accounts()->whereIn('account_type_id', $accountTypeIds)->where('id', '!=', $ignore) $result = auth()->user()->accounts()->whereIn('account_type_id', $accountTypeIds)->where('id', '!=', $ignore)
->where('name', $value) ->where('name', $value)
->first() ->first();
;
return null === $result; return null === $result;
} }
@ -541,8 +556,7 @@ class FireflyValidator extends Validator
/** @var null|Account $result */ /** @var null|Account $result */
$result = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore) $result = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value) ->where('name', $value)
->first() ->first();
;
return null === $result; return null === $result;
} }
@ -560,8 +574,7 @@ class FireflyValidator extends Validator
$entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore) $entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value) ->where('name', $value)
->first() ->first();
;
return null === $entry; return null === $entry;
} }
@ -579,8 +592,7 @@ class FireflyValidator extends Validator
$entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore) $entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value) ->where('name', $value)
->first() ->first();
;
return null === $entry; return null === $entry;
} }
@ -608,8 +620,7 @@ class FireflyValidator extends Validator
->whereNull('accounts.deleted_at') ->whereNull('accounts.deleted_at')
->where('accounts.user_id', auth()->user()->id) ->where('accounts.user_id', auth()->user()->id)
->where('account_meta.name', 'account_number') ->where('account_meta.name', 'account_number')
->where('account_meta.data', json_encode($value)) ->where('account_meta.data', json_encode($value));
;
if ($accountId > 0) { if ($accountId > 0) {
// exclude current account from check. // exclude current account from check.
@ -716,8 +727,7 @@ class FireflyValidator extends Validator
->where('response', $response) ->where('response', $response)
->where('delivery', $delivery) ->where('delivery', $delivery)
->where('id', '!=', $existingId) ->where('id', '!=', $existingId)
->where('url', $url)->count() ->where('url', $url)->count();
;
} }
return false; return false;
@ -753,8 +763,7 @@ class FireflyValidator extends Validator
$result = \DB::table($table)->where('user_id', auth()->user()->id)->whereNull('deleted_at') $result = \DB::table($table)->where('user_id', auth()->user()->id)->whereNull('deleted_at')
->where('id', '!=', $exclude) ->where('id', '!=', $exclude)
->where($field, $value) ->where($field, $value)
->first([$field]) ->first([$field]);
;
if (null === $result) { if (null === $result) {
return true; // not found, so true. return true; // not found, so true.
} }
@ -776,8 +785,7 @@ class FireflyValidator extends Validator
$query = \DB::table('object_groups') $query = \DB::table('object_groups')
->whereNull('object_groups.deleted_at') ->whereNull('object_groups.deleted_at')
->where('object_groups.user_id', auth()->user()->id) ->where('object_groups.user_id', auth()->user()->id)
->where('object_groups.title', $value) ->where('object_groups.title', $value);
;
if (null !== $exclude) { if (null !== $exclude) {
$query->where('object_groups.id', '!=', (int) $exclude); $query->where('object_groups.id', '!=', (int) $exclude);
} }
@ -796,8 +804,7 @@ class FireflyValidator extends Validator
{ {
$exclude = $parameters[0] ?? null; $exclude = $parameters[0] ?? null;
$query = \DB::table('piggy_banks')->whereNull('piggy_banks.deleted_at') $query = \DB::table('piggy_banks')->whereNull('piggy_banks.deleted_at')
->leftJoin('accounts', 'accounts.id', '=', 'piggy_banks.account_id')->where('accounts.user_id', auth()->user()->id) ->leftJoin('accounts', 'accounts.id', '=', 'piggy_banks.account_id')->where('accounts.user_id', auth()->user()->id);
;
if (null !== $exclude) { if (null !== $exclude) {
$query->where('piggy_banks.id', '!=', (int) $exclude); $query->where('piggy_banks.id', '!=', (int) $exclude);
} }
@ -830,8 +837,7 @@ class FireflyValidator extends Validator
->where('trigger', $trigger) ->where('trigger', $trigger)
->where('response', $response) ->where('response', $response)
->where('delivery', $delivery) ->where('delivery', $delivery)
->where('url', $url)->count() ->where('url', $url)->count();
;
} }
return false; return false;

View File

@ -80,4 +80,10 @@ return [
'revenue_accounts' => 'Revenue accounts', 'revenue_accounts' => 'Revenue accounts',
'liabilities_accounts' => 'Liabilities', 'liabilities_accounts' => 'Liabilities',
'placeholder' => '[Placeholder]', 'placeholder' => '[Placeholder]',
// mfa
'profile_mfa' => 'Multi-factor authentication',
'mfa_enableMFA' => 'Enable multi-factor authentication',
'mfa_backup_codes' => 'Backup codes',
'mfa_disableMFA' => 'Disable multi-factor authentication',
]; ];

View File

@ -134,5 +134,40 @@ return [
'bill_warning_end_date_zero' => 'Your bill **":name"** is due to end on :date. This moment will pass **TODAY!**', 'bill_warning_end_date_zero' => 'Your bill **":name"** is due to end on :date. This moment will pass **TODAY!**',
'bill_warning_extension_date_zero' => 'Your bill **":name"** is due to be extended or cancelled on :date. This moment will pass **TODAY!**', 'bill_warning_extension_date_zero' => 'Your bill **":name"** is due to be extended or cancelled on :date. This moment will pass **TODAY!**',
'bill_warning_please_action' => 'Please take the appropriate action.', 'bill_warning_please_action' => 'Please take the appropriate action.',
// user has enabled MFA
'enabled_mfa_subject' => 'You have enabled multi-factor authentication',
'enabled_mfa_slack' => 'You (:email) have enabled multi-factor authentication. Is this not correct? Check your settings!',
'have_enabled_mfa' => 'You have enabled multi-factor authentication on your Firefly III account ":email". This means that you will need to use an authenticator app to log in from now on.',
'enabled_mfa_warning' => 'If you did not enable this, please contact your administrator immediately or check out the Firefly III documentation.',
'disabled_mfa_subject' => 'You have disabled multi-factor authentication!',
'disabled_mfa_slack' => 'You (:email) have disabled multi-factor authentication. Is this not correct? Check your settings!',
'have_disabled_mfa' => 'You have disabled multi-factor authentication on your Firefly III account ":email".',
'disabled_mfa_warning' => 'If you did not disable this, please contact your administrator immediately or check out the Firefly III documentation.',
'new_backup_codes_subject' => 'You have generated new back-up codes',
'new_backup_codes_slack' => 'You (:email) have generated new back-up codes. These can be used to login to Firefly III. Is this not correct? Check your settings!',
'new_backup_codes_intro' => 'You (:email) have generated new back-up codes. These can be used to login to Firefly III if you lose access to your authenticator app.',
'new_backup_codes_warning' => 'Please store these codes in a safe place. If you lose them, you will not be able to log in to Firefly III. If you did not do this, please contact your administrator immediately or check out the Firefly III documentation.',
'used_backup_code_subject' => 'You have used a back-up code to login',
'used_backup_code_slack' => 'You (:email) have used a back-up code to login',
'used_backup_code_intro' => 'You (:email) have used a back-up code to login to Firefly III. You now have one less back-up code to login with. Please remove it from your list.',
'used_backup_code_warning' => 'If you did not do this, please contact your administrator immediately or check out the Firefly III documentation.',
// few left:
'mfa_few_backups_left_subject' => 'You have only :count backup code(s) left!',
'mfa_few_backups_left_slack' => 'You (:email) have only :count backup code(s) left!',
'few_backup_codes_intro' => 'You (:email) have used most of your backup codes, and now have only :count left. Please generate new ones as soon as possible.',
'few_backup_codes_warning' => 'Without backup codes, you cannot recover your MFA login if you lose access to your code generator.',
// NO left:
'mfa_no_backups_left_subject' => 'You have NO backup codes left!',
'mfa_no_backups_left_slack' => 'You (:email) NO backup codes left!',
'no_backup_codes_intro' => 'You (:email) have used ALL of your backup codes. Please generate new ones as soon as possible.',
'no_backup_codes_warning' => 'Without backup codes, you cannot recover your MFA login if you lose access to your code generator.',
]; ];
// Ignore this comment // Ignore this comment

View File

@ -1320,18 +1320,18 @@ return [
'pref_custom_fiscal_year_label' => 'Enabled', 'pref_custom_fiscal_year_label' => 'Enabled',
'pref_custom_fiscal_year_help' => 'In countries that use a financial year other than January 1 to December 31, you can switch this on and specify start / end days of the fiscal year', 'pref_custom_fiscal_year_help' => 'In countries that use a financial year other than January 1 to December 31, you can switch this on and specify start / end days of the fiscal year',
'pref_fiscal_year_start_label' => 'Fiscal year start date', 'pref_fiscal_year_start_label' => 'Fiscal year start date',
'pref_two_factor_auth' => '2-step verification', 'pref_two_factor_auth' => 'Multi-factor authentication',
'pref_two_factor_auth_help' => 'When you enable 2-step verification (also known as two-factor authentication), you add an extra layer of security to your account. You sign in with something you know (your password) and something you have (a verification code). Verification codes are generated by an application on your phone, such as Authy or Google Authenticator.', 'pref_two_factor_auth_help' => 'When you enable multi-factor authentication (also known as two-factor authentication), you add an extra layer of security to your account. You sign in with something you know (your password) and something you have (a verification code). Verification codes are generated by an application on your phone, such as Authy or Google Authenticator.',
'pref_enable_two_factor_auth' => 'Enable 2-step verification', 'pref_enable_two_factor_auth' => 'Enable multi-factor authentication',
'pref_two_factor_auth_disabled' => '2-step verification code removed and disabled', 'pref_two_factor_auth_disabled' => 'Multi-factor authentication verification code removed and disabled',
'pref_two_factor_auth_remove_it' => 'Don\'t forget to remove the account from your authentication app!', 'pref_two_factor_auth_remove_it' => 'Don\'t forget to remove the account from your authentication app!',
'pref_two_factor_auth_code' => 'Verify code', 'pref_two_factor_auth_code' => 'Verify code',
'pref_two_factor_auth_code_help' => 'Scan the QR code with an application on your phone such as Authy or Google Authenticator and enter the generated code.', 'pref_two_factor_auth_code_help' => 'Scan the QR code with an application on your phone such as Authy or Google Authenticator and enter the generated code. The QR code changes every time you visit this page. Make sure you use the most recent one.',
'pref_two_factor_auth_reset_code' => 'Reset verification code', 'pref_two_factor_auth_reset_code' => 'Reset verification code',
'pref_two_factor_auth_disable_2fa' => 'Disable 2FA', 'pref_two_factor_auth_disable_2fa' => 'Disable MFA',
'2fa_use_secret_instead' => 'If you cannot scan the QR code, feel free to use the secret instead: <code>:secret</code>.', '2fa_use_secret_instead' => 'If you cannot scan the QR code, feel free to use the secret instead: <code>:secret</code>.',
'2fa_backup_codes' => 'Store these backup codes for access in case you lose your device.', '2fa_backup_codes' => 'Store these backup codes for access in case you lose your device.',
'2fa_already_enabled' => '2-step verification is already enabled.', '2fa_already_enabled' => 'Multi-factor authentication verification is already enabled.',
'wrong_mfa_code' => 'This MFA code is not valid.', 'wrong_mfa_code' => 'This MFA code is not valid.',
'pref_save_settings' => 'Save settings', 'pref_save_settings' => 'Save settings',
'saved_preferences' => 'Preferences saved!', 'saved_preferences' => 'Preferences saved!',
@ -1413,7 +1413,27 @@ return [
'administration_role_view_reports' => 'View reports', 'administration_role_view_reports' => 'View reports',
'administration_role_full' => 'Full access', 'administration_role_full' => 'Full access',
// mfa
'enable_mfa' => 'Enable multi-factor authentication',
'mfa_index_title' => 'Multi-factor authentication',
'mfa_index_intro' => 'Firefly III supports multi-factor authentication (MFA). You can enable MFA for your account to add an extra layer of security. Applications like Authy, Google Authenticator and FreeOTP can be used to generate the codes you need to log in. Security keys are not supported by Firefly III but you can use a security key as a storage device for your MFA secret.',
'mfa_index_enabled' => 'Multi-factor authentication is enabled for your account.',
'mfa_index_disabled' => 'Multi-factor authentication is not enabled for your account.',
'mfa_index_owner' => 'The owner of this instance will always be able to disable multi-factor authentication for your account.',
'current_password_confirm_mfa' => 'Enter your current password',
'mfa_warning_code_changes' => 'You may get a MFA dialog after you entered your password and a MFA code. In that case, please wait for your application to generate a new MFA code, and do not recycle the one you just used.',
'mfa_already_disabled' => 'Multi-factor authentication is not enabled, so you cannot disable it.',
'disable_mfa_page' => 'Disable multi-factor authentication',
'disable_mfa_intro' => 'You can disable multi-factor authentication. To do so, please enter your password and a multi-factor authentication code. If you want to disable multi-factor authentication because you have lost access to your code generator, <a href="https://docs.firefly-iii.org/references/faq/firefly-iii/using/#i-lost-my-2fa-token-generator-or-2fa-has-stopped-working">please refer to the documentation instead</a>.',
'pref_disable_mfa' => 'Disable multi-factor authentication',
'mfa_not_enabled' => 'Multi-factor authentication is not enabled.',
'mfa_backup_codes_intro' => 'Firefly III can generate backup codes for you. These codes can be used to log in when you cannot use your code generator. You can generate a new set of codes at any time. If you generate a new set, the old set will be invalidated.',
'mfa_backup_codes_quick' => 'If you are very fast coming from the setup page of multi-factor authentication, your app may not have generated a new code yet. Please know that MFA codes can only be used once. Make sure you use a different code from the previous one.',
'mfa_backup_codes_title' => 'Multi-factor authentication backup codes',
'mfa_backup_codes_post_title' => 'Multi-factor authentication backup codes',
// profile: // profile:
'manage_mfa_settings' => 'Manage multi-factor authentication settings',
'purge_data_title' => 'Purge data from Firefly III', 'purge_data_title' => 'Purge data from Firefly III',
'purge_data_expl' => '"Purging" means "deleting that which is already deleted". In normal circumstances, Firefly III deletes nothing permanently. It just hides it. The button below deletes all of these previously "deleted" records FOREVER.', 'purge_data_expl' => '"Purging" means "deleting that which is already deleted". In normal circumstances, Firefly III deletes nothing permanently. It just hides it. The button below deletes all of these previously "deleted" records FOREVER.',
'delete_stuff_header' => 'Delete and purge data', 'delete_stuff_header' => 'Delete and purge data',
@ -1471,6 +1491,7 @@ return [
'delete_your_account_password' => 'Enter your password to continue.', 'delete_your_account_password' => 'Enter your password to continue.',
'password' => 'Password', 'password' => 'Password',
'are_you_sure' => 'Are you sure? You cannot undo this.', 'are_you_sure' => 'Are you sure? You cannot undo this.',
'are_you_sure_confirm' => 'Are you sure?',
'delete_account_button' => 'DELETE your account', 'delete_account_button' => 'DELETE your account',
'invalid_current_password' => 'Invalid current password!', 'invalid_current_password' => 'Invalid current password!',
'password_changed' => 'Password changed!', 'password_changed' => 'Password changed!',

View File

@ -272,6 +272,7 @@ return [
'no_auth_user_group' => 'You have to be logged in to access this administration.', 'no_auth_user_group' => 'You have to be logged in to access this administration.',
'no_access_user_group' => 'You do not have the correct access rights for this administration.', 'no_access_user_group' => 'You do not have the correct access rights for this administration.',
'administration_owner_rename' => 'You can\'t rename your standard administration.', 'administration_owner_rename' => 'You can\'t rename your standard administration.',
'existing_mfa_code' => 'Please enter a valid code',
]; ];
// Ignore this comment // Ignore this comment

View File

@ -0,0 +1,6 @@
@component('mail::message')
{{ trans('email.have_disabled_mfa', ['email' => $user->email]) }}
{{ trans('email.disabled_mfa_warning') }}
@endcomponent

View File

@ -0,0 +1,6 @@
@component('mail::message')
{{ trans('email.have_enabled_mfa', ['email' => $user->email]) }}
{{ trans('email.enabled_mfa_warning') }}
@endcomponent

View File

@ -0,0 +1,6 @@
@component('mail::message')
{{ trans('email.few_backup_codes_intro', ['email' => $user->email, 'count' => $count]) }}
{{ trans('email.few_backup_codes_warning') }}
@endcomponent

View File

@ -0,0 +1,6 @@
@component('mail::message')
{{ trans('email.new_backup_codes_intro', ['email' => $user->email]) }}
{{ trans('email.new_backup_codes_warning') }}
@endcomponent

View File

@ -0,0 +1,6 @@
@component('mail::message')
{{ trans('email.no_backup_codes_intro', ['email' => $user->email]) }}
{{ trans('email.no_backup_codes_warning') }}
@endcomponent

View File

@ -0,0 +1,6 @@
@component('mail::message')
{{ trans('email.used_backup_code_intro', ['email' => $user->email]) }}
{{ trans('email.used_backup_code_warning') }}
@endcomponent

View File

@ -22,12 +22,14 @@
<li role="presentation"> <li role="presentation">
<a href="#oauth" aria-controls="messages" role="tab" data-toggle="tab">{{ 'oauth'|_ }}</a> <a href="#oauth" aria-controls="messages" role="tab" data-toggle="tab">{{ 'oauth'|_ }}</a>
</li> </li>
{#
{% if true == isInternalAuth %} {% if true == isInternalAuth %}
<li role="presentation"> <li role="presentation">
<a href="#mfa" aria-controls="settings" role="tab" <a href="#mfa" aria-controls="settings" role="tab"
data-toggle="tab">{{ 'pref_two_factor_auth'|_ }}</a> data-toggle="tab">{{ 'pref_two_factor_auth'|_ }}</a>
</li> </li>
{% endif %} {% endif %}
#}
<li role="presentation"> <li role="presentation">
<a href="#delete" aria-controls="settings" role="tab" <a href="#delete" aria-controls="settings" role="tab"
data-toggle="tab">{{ 'delete_stuff_header'|_ }}</a> data-toggle="tab">{{ 'delete_stuff_header'|_ }}</a>
@ -52,6 +54,16 @@
<li> <li>
<a href="{{ route('profile.change-password') }}">{{ 'change_your_password'|_ }}</a> <a href="{{ route('profile.change-password') }}">{{ 'change_your_password'|_ }}</a>
</li> </li>
{% if enabled2FA == true %}
<li>
<a href="{{ route('profile.mfa.index') }}">{{ 'manage_mfa_settings'|_ }}</a>
</li>
{% endif %}
{% if enabled2FA == false %}
<li>
<a href="{{ route('profile.mfa.index') }}">{{ 'enable_mfa'|_ }}</a>
</li>
{% endif %}
{% endif %} {% endif %}
<li><a href="{{ route('logout') }}" class="logout-link">{{ 'logout'|_ }}</a> <li><a href="{{ route('logout') }}" class="logout-link">{{ 'logout'|_ }}</a>
@ -103,6 +115,7 @@
<div id="passport_clients"></div> <div id="passport_clients"></div>
</div> </div>
{#
{% if true == isInternalAuth %} {% if true == isInternalAuth %}
<!-- MFA --> <!-- MFA -->
<div role="tabpanel" class="tab-pane" id="mfa"> <div role="tabpanel" class="tab-pane" id="mfa">
@ -141,6 +154,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
#}
<!-- purge stuff --> <!-- purge stuff -->
<div role="tabpanel" class="tab-pane" id="delete"> <div role="tabpanel" class="tab-pane" id="delete">

View File

@ -0,0 +1,46 @@
{% extends './layout/default' %}
{% block breadcrumbs %}
{{ Breadcrumbs.render(Route.getCurrentRoute.getName) }}
{% endblock %}
{% block content %}
<form method="POST" action="{{ route('profile.mfa.backup-codes.post') }}" accept-charset="UTF-8" class="form-horizontal" id="preferences_code">
<input name="_token" type="hidden" value="{{ csrf_token() }}">
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">{{ 'mfa_backup_codes_title'|_ }}</h3>
</div>
<div class="box-body">
<div class="form group">
<p>
{{ 'mfa_backup_codes_intro'|_ }}
</p>
<p class="text-danger">
{{ 'mfa_backup_codes_quick'|_ }}
</p>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
<div class="box">
<div class="box-body">
{{ ExpandedForm.password('password', {helpText: 'current_password_confirm_mfa'|_}) }}
{{ ExpandedForm.text('code', code) }}
</div>
<div class="box-footer">
<button type="submit" class="btn btn-success">{{ 'pref_save_settings'|_ }}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends './layout/default' %}
{% block breadcrumbs %}
{{ Breadcrumbs.render(Route.getCurrentRoute.getName) }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">{{ 'mfa_backup_codes_post_title'|_ }}</h3>
</div>
<div class="box-body">
<div class="form group">
<p>
{{ '2fa_backup_codes'|_ }}
</p>
<textarea rows="10" class="form-control" readonly>{{ codes }}</textarea>
</div>
</div>
<div class="box-footer">
<a class="btn btn-success" href="{{ route('profile.mfa.index') }}">{{ '2fa_i_have_them'|_ }}</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends './layout/default' %}
{% block breadcrumbs %}
{{ Breadcrumbs.render(Route.getCurrentRoute.getName) }}
{% endblock %}
{% block content %}
<form method="POST" action="{{ route('profile.mfa.disableMFA.post') }}" accept-charset="UTF-8" class="form-horizontal">
<input name="_token" type="hidden" value="{{ csrf_token() }}">
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">{{ 'disable_mfa_page'|_ }}</h3>
</div>
<div class="box-body">
<p class="hidden-print">
{{ 'disable_mfa_intro'|_ }}
</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
<div class="box">
<div class="box-body">
{{ ExpandedForm.password('password', {helpText: 'current_password_confirm_mfa'|_}) }}
{{ ExpandedForm.text('code', code) }}
</div>
<div class="box-footer">
<button type="submit" class="btn btn-danger">{{ 'pref_disable_mfa'|_ }}</button>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<script type="text/javascript" nonce="{{ JS_NONCE }}">
$(function () {
"use strict";
// Focus first visible form element.
$("form#preferences_code input:enabled:visible:first").first().select();
});
</script>
{% endblock %}

View File

@ -5,7 +5,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<form method="POST" action="{{ route('profile.code.store') }}" accept-charset="UTF-8" class="form-horizontal" id="preferences_code"> <form method="POST" action="{{ route('profile.mfa.enableMFA.post') }}" accept-charset="UTF-8" class="form-horizontal" id="preferences_code">
<input name="_token" type="hidden" value="{{ csrf_token() }}"> <input name="_token" type="hidden" value="{{ csrf_token() }}">
<div class="row"> <div class="row">
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12"> <div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
@ -14,7 +14,7 @@
<h3 class="box-title">{{ 'pref_two_factor_auth_code'|_ }}</h3> <h3 class="box-title">{{ 'pref_two_factor_auth_code'|_ }}</h3>
</div> </div>
<div class="box-body"> <div class="box-body">
<p class="text-info hidden-print"> <p class="hidden-print">
{{ 'pref_two_factor_auth_code_help'|_ }} {{ 'pref_two_factor_auth_code_help'|_ }}
</p> </p>
<div class="form group"> <div class="form group">
@ -24,10 +24,9 @@
<p class="hidden-print"> <p class="hidden-print">
{{ trans('firefly.2fa_use_secret_instead', {secret: secret|escape})|raw }} {{ trans('firefly.2fa_use_secret_instead', {secret: secret|escape})|raw }}
</p> </p>
<p> <p class="hidden-print text-danger">
{{ '2fa_backup_codes'|_ }} {{ 'mfa_warning_code_changes'|_ }}
</p> </p>
<pre>{{ codes }}</pre>
</div> </div>
</div> </div>
</div> </div>
@ -37,6 +36,7 @@
<div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12"> <div class="col-lg-6 col-lg-offset-3 col-md-12 col-sm-12 col-xs-12">
<div class="box"> <div class="box">
<div class="box-body"> <div class="box-body">
{{ ExpandedForm.password('password', {helpText: 'current_password_confirm_mfa'|_}) }}
{{ ExpandedForm.text('code', code) }} {{ ExpandedForm.text('code', code) }}
</div> </div>
<div class="box-footer"> <div class="box-footer">

View File

@ -0,0 +1,48 @@
{% extends './layout/default' %}
{% block breadcrumbs %}
{{ Breadcrumbs.render(Route.getCurrentRoute.getName) }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8 col-lg-offset-2 col-md-12 col-sm-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">
{{ 'mfa_index_title'|_ }}
</h3>
</div>
<div class="box-body">
<p>
{% if enabledMFA == true %}
{{ 'mfa_index_enabled'|_ }}
{% endif %}
{% if enabledMFA == false %}
{{ 'mfa_index_disabled'|_ }}
{% endif %}
</p>
<p>
{{ 'mfa_index_intro'|_ }}
</p>
<p>
{{ 'mfa_index_owner'|_ }}
</p>
{% if enabledMFA == true %}
<div class="btn-group">
<a href="{{ route('profile.mfa.disableMFA') }}" class="btn btn-danger"><em class="fa fa-unlock-alt"></em> {{ 'pref_two_factor_auth_disable_2fa'|_ }}</a>
<a href="{{ route('profile.mfa.backup-codes') }}" class="btn btn-default"><em class="fa fa-calculator"></em> {{ 'pref_two_factor_new_backup_codes'|_ }}</a>
</div>
{% endif %}
{% if enabledMFA == false %}
<p>
<a class="btn btn-info" href="{{ route('profile.mfa.enableMFA') }}"><em class="fa fa-calculator"></em> {{ 'pref_enable_two_factor_auth'|_ }}</a>
</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -687,13 +687,7 @@ Breadcrumbs::for(
} }
); );
Breadcrumbs::for(
'profile.code',
static function (Generator $breadcrumbs): void {
$breadcrumbs->parent('home');
$breadcrumbs->push(trans('breadcrumbs.profile'), route('profile.index'));
}
);
Breadcrumbs::for( Breadcrumbs::for(
'profile.new-backup-codes', 'profile.new-backup-codes',
@ -711,6 +705,47 @@ Breadcrumbs::for(
} }
); );
// Profile MFA
Breadcrumbs::for(
'profile.mfa.index',
static function (Generator $breadcrumbs): void {
$breadcrumbs->parent('profile.index');
$breadcrumbs->push(trans('breadcrumbs.profile_mfa'), route('profile.mfa.index'));
}
);
Breadcrumbs::for(
'profile.mfa.enableMFA',
static function (Generator $breadcrumbs): void {
$breadcrumbs->parent('profile.mfa.index');
$breadcrumbs->push(trans('breadcrumbs.mfa_enableMFA'), route('profile.mfa.enableMFA'));
}
);
Breadcrumbs::for(
'profile.mfa.disableMFA',
static function (Generator $breadcrumbs): void {
$breadcrumbs->parent('profile.mfa.index');
$breadcrumbs->push(trans('breadcrumbs.mfa_disableMFA'), route('profile.mfa.disableMFA'));
}
);
Breadcrumbs::for(
'profile.mfa.backup-codes',
static function (Generator $breadcrumbs): void {
$breadcrumbs->parent('profile.mfa.index');
$breadcrumbs->push(trans('breadcrumbs.mfa_backup_codes'), route('profile.mfa.backup-codes'));
}
);
Breadcrumbs::for(
'profile.mfa.backup-codes.post',
static function (Generator $breadcrumbs): void {
$breadcrumbs->parent('profile.mfa.index');
$breadcrumbs->push(trans('breadcrumbs.mfa_backup_codes'), route('profile.mfa.backup-codes'));
}
);
// PROFILE // PROFILE
Breadcrumbs::for( Breadcrumbs::for(
'profile.index', 'profile.index',

View File

@ -825,15 +825,35 @@ Route::group(
Route::get('logout-others', ['uses' => 'ProfileController@logoutOtherSessions', 'as' => 'logout-others']); Route::get('logout-others', ['uses' => 'ProfileController@logoutOtherSessions', 'as' => 'logout-others']);
Route::post('logout-others', ['uses' => 'ProfileController@postLogoutOtherSessions', 'as' => 'logout-others.post']); Route::post('logout-others', ['uses' => 'ProfileController@postLogoutOtherSessions', 'as' => 'logout-others.post']);
// new 2FA routes
Route::post('enable2FA', ['uses' => 'ProfileController@enable2FA', 'as' => 'enable2FA']);
Route::get('2fa/code', ['uses' => 'ProfileController@code', 'as' => 'code']);
Route::post('2fa/code', ['uses' => 'ProfileController@postCode', 'as' => 'code.store']);
Route::post('/delete-code', ['uses' => 'ProfileController@deleteCode', 'as' => 'delete-code']);
Route::post('2fa/new-codes', ['uses' => 'ProfileController@newBackupCodes', 'as' => 'new-backup-codes']);
} }
); );
// MFA controller
Route::group(
['middleware' => 'user-full-auth', 'namespace' => 'FireflyIII\Http\Controllers', 'prefix' => 'mfa', 'as' => 'profile.mfa.'],
static function (): void {
Route::get('index', ['uses' => 'Profile\MfaController@index', 'as' => 'index']);
// enable MFA (goes to code page)
Route::get('enableMFA', ['uses' => 'Profile\MfaController@enableMFA', 'as' => 'enableMFA']);
Route::post('enableMFA', ['uses' => 'Profile\MfaController@enableMFAPost', 'as' => 'enableMFA.post']);
// show backup codes
Route::get('backup-codes', ['uses' => 'Profile\MfaController@backupCodes', 'as' => 'backup-codes']);
Route::post('backup-codes', ['uses' => 'Profile\MfaController@backupCodesPost', 'as' => 'backup-codes.post']);
// enable MFA
// Route::get('2fa/code', ['uses' => 'Profile\MfaController@code', 'as' => 'code']);
// disable MFA
Route::get('/disableMFA', ['uses' => 'Profile\MfaController@disableMFA', 'as' => 'disableMFA']);
Route::post('/disableMFA', ['uses' => 'Profile\MfaController@disableMFAPost', 'as' => 'disableMFA.post']);
}
);
// Recurring Transactions Controller. // Recurring Transactions Controller.
Route::group( Route::group(
['middleware' => 'user-full-auth', 'namespace' => 'FireflyIII\Http\Controllers', 'prefix' => 'recurring', 'as' => 'recurring.'], ['middleware' => 'user-full-auth', 'namespace' => 'FireflyIII\Http\Controllers', 'prefix' => 'recurring', 'as' => 'recurring.'],