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;
use FireflyIII\Events\Security\MFABackupFewLeft;
use FireflyIII\Events\Security\MFABackupNoLeft;
use FireflyIII\Events\Security\MFAUsedBackupCode;
use FireflyIII\Http\Controllers\Controller;
use FireflyIII\User;
use Illuminate\Contracts\View\Factory;
@ -30,6 +33,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Log;
use PragmaRX\Google2FALaravel\Support\Authenticator;
/**
@ -47,7 +51,7 @@ class TwoFactorController extends Controller
/** @var User $user */
$user = auth()->user();
$siteOwner = config('firefly.site_owner');
$title = (string)trans('firefly.two_factor_forgot_title');
$title = (string) trans('firefly.two_factor_forgot_title');
return view('auth.lost-two-factor', compact('user', 'siteOwner', 'title'));
}
@ -58,8 +62,8 @@ class TwoFactorController extends Controller
public function submitMFA(Request $request)
{
/** @var array $mfaHistory */
$mfaHistory = app('preferences')->get('mfa_history', [])->data;
$mfaCode = (string)$request->get('one_time_password');
$mfaHistory = app('preferences')->get('mfa_history', [])->data;
$mfaCode = (string) $request->get('one_time_password');
// is in history? then refuse to use it.
if ($this->inMFAHistory($mfaCode, $mfaHistory)) {
@ -86,6 +90,10 @@ class TwoFactorController extends Controller
$authenticator->login();
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'));
}
@ -170,11 +178,25 @@ class TwoFactorController extends Controller
*/
private function removeFromBackupCodes(string $mfaCode): void
{
$list = app('preferences')->get('mfa_recovery', [])->data;
$list = app('preferences')->get('mfa_recovery', [])->data;
if (!is_array($list)) {
$list = [];
}
$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);
}
}

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']);
}
/**
* 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.
*
@ -193,61 +134,6 @@ class ProfileController extends Controller
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.
*
@ -298,33 +184,6 @@ class ProfileController extends Controller
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.
*/
@ -442,99 +301,6 @@ class ProfileController extends Controller
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.
*
@ -664,7 +430,7 @@ class ProfileController extends Controller
$repository->changeEmail($user, $match);
$repository->unblockUser($user);
// return to login.
// return to login page.
session()->flash('success', (string)trans('firefly.login_with_old_email'));
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
return [
'password' => 'required|currentPassword',
'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\RequestedSendWebhookMessages;
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\StoredTransactionGroup;
use FireflyIII\Events\TriggeredAuditLog;
@ -205,6 +211,26 @@ class EventServiceProvider extends ServiceProvider
RuleActionFailedOnObject::class => [
'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

@ -62,7 +62,7 @@ class FireflyValidator extends Validator
if (!is_string($value) || 6 !== strlen($value)) {
return false;
}
$user = auth()->user();
$user = auth()->user();
if (null === $user) {
app('log')->error('No user during validate2faCode');
@ -73,9 +73,25 @@ class FireflyValidator extends Validator
if (is_array($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
@ -88,7 +104,7 @@ class FireflyValidator extends Validator
{
$field = $parameters[1] ?? 'id';
if (0 === (int)$value) {
if (0 === (int) $value) {
return true;
}
$count = \DB::table($parameters[0])->where('user_id', auth()->user()->id)->where($field, $value)->count();
@ -179,15 +195,15 @@ class FireflyValidator extends Validator
$value = strtoupper($value);
// replace characters outside of ASCI range.
$value = (string)iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
$value = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
$search = [' ', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
$replace = ['', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35'];
// take
$first = substr($value, 0, 4);
$last = substr($value, 4);
$iban = $last.$first;
$iban = trim(str_replace($search, $replace, $iban));
$first = substr($value, 0, 4);
$last = substr($value, 4);
$iban = $last . $first;
$iban = trim(str_replace($search, $replace, $iban));
if ('' === $iban) {
return false;
}
@ -202,7 +218,7 @@ class FireflyValidator extends Validator
return false;
}
return 1 === (int)$checksum;
return 1 === (int) $checksum;
}
/**
@ -217,7 +233,7 @@ class FireflyValidator extends Validator
/** @var mixed $compare */
$compare = $parameters[0] ?? '0';
return bccomp((string)$value, (string)$compare) < 0;
return bccomp((string) $value, (string) $compare) < 0;
}
/**
@ -232,7 +248,7 @@ class FireflyValidator extends Validator
/** @var mixed $compare */
$compare = $parameters[0] ?? '0';
return bccomp((string)$value, (string)$compare) > 0;
return bccomp((string) $value, (string) $compare) > 0;
}
/**
@ -246,7 +262,7 @@ class FireflyValidator extends Validator
{
$field = $parameters[1] ?? 'id';
if (0 === (int)$value) {
if (0 === (int) $value) {
return true;
}
$count = \DB::table($parameters[0])->where($field, $value)->count();
@ -258,8 +274,8 @@ class FireflyValidator extends Validator
{
// first, get the index from this string:
$value ??= '';
$parts = explode('.', $attribute);
$index = (int)($parts[1] ?? '0');
$parts = explode('.', $attribute);
$index = (int) ($parts[1] ?? '0');
// get the name of the trigger from the data array:
$actionType = $this->data['actions'][$index]['type'] ?? 'invalid';
@ -328,8 +344,8 @@ class FireflyValidator extends Validator
public function validateRuleTriggerValue(string $attribute, ?string $value = null): bool
{
// first, get the index from this string:
$parts = explode('.', $attribute);
$index = (int)($parts[1] ?? '0');
$parts = explode('.', $attribute);
$index = (int) ($parts[1] ?? '0');
// get the name of the trigger from the data array:
$triggerType = $this->data['triggers'][$index]['type'] ?? 'invalid';
@ -340,14 +356,14 @@ class FireflyValidator extends Validator
}
// these trigger types need a numerical check:
$numerical = ['amount_less', 'amount_more', 'amount_exactly'];
$numerical = ['amount_less', 'amount_more', 'amount_exactly'];
if (in_array($triggerType, $numerical, true)) {
return is_numeric($value);
}
// these triggers need just the word "true":
// TODO create a helper to automatically return these.
$needTrue = [
$needTrue = [
'reconciled', 'has_attachments', 'has_any_category', 'has_any_budget', 'has_any_bill', 'has_any_tag', 'any_notes', 'any_external_url', 'has_no_attachments', 'has_no_category', 'has_no_budget', 'has_no_bill', 'has_no_tag', 'no_notes', 'no_external_url',
'source_is_cash',
'destination_is_cash',
@ -362,7 +378,7 @@ class FireflyValidator extends Validator
// these trigger types need a simple strlen check:
// TODO create a helper to automatically return these.
$length = [
$length = [
'source_account_starts',
'source_account_ends',
'source_account_is',
@ -391,7 +407,7 @@ class FireflyValidator extends Validator
// check if it's an existing account.
// TODO create a helper to automatically return these.
if (in_array($triggerType, ['destination_account_id', 'source_account_id'], true)) {
return is_numeric($value) && (int)$value > 0;
return is_numeric($value) && (int) $value > 0;
}
// check transaction type.
@ -430,7 +446,7 @@ class FireflyValidator extends Validator
{
$verify = false;
if (array_key_exists('verify_password', $this->data)) {
$verify = 1 === (int)$this->data['verify_password'];
$verify = 1 === (int) $this->data['verify_password'];
}
if ($verify) {
/** @var Verifier $service */
@ -465,7 +481,7 @@ class FireflyValidator extends Validator
if (array_key_exists('type', $this->data)) {
app('log')->debug('validateUniqueAccountForUser::typeString');
return $this->validateByAccountTypeString($value, $parameters, (string)$this->data['type']);
return $this->validateByAccountTypeString($value, $parameters, (string) $this->data['type']);
}
if (array_key_exists('account_type_id', $this->data)) {
app('log')->debug('validateUniqueAccountForUser::typeId');
@ -476,7 +492,7 @@ class FireflyValidator extends Validator
if (null !== $parameterId) {
app('log')->debug('validateUniqueAccountForUser::paramId');
return $this->validateByParameterId((int)$parameterId, $value);
return $this->validateByParameterId((int) $parameterId, $value);
}
if (array_key_exists('id', $this->data)) {
app('log')->debug('validateUniqueAccountForUser::accountId');
@ -497,9 +513,9 @@ class FireflyValidator extends Validator
}
/** @var User $user */
$user = User::find($this->data['user_id']);
$type = AccountType::find($this->data['account_type_id'])->first();
$value = $this->data['name'];
$user = User::find($this->data['user_id']);
$type = AccountType::find($this->data['account_type_id'])->first();
$value = $this->data['name'];
/** @var null|Account $result */
$result = $user->accounts()->where('account_type_id', $type->id)->where('name', $value)->first();
@ -510,21 +526,20 @@ class FireflyValidator extends Validator
private function validateByAccountTypeString(string $value, array $parameters, string $type): bool
{
/** @var null|array $search */
$search = \Config::get('firefly.accountTypeByIdentifier.'.$type);
$search = \Config::get('firefly.accountTypeByIdentifier.' . $type);
if (null === $search) {
return false;
}
$accountTypes = AccountType::whereIn('type', $search)->get();
$ignore = (int)($parameters[0] ?? 0.0);
$ignore = (int) ($parameters[0] ?? 0.0);
$accountTypeIds = $accountTypes->pluck('id')->toArray();
/** @var null|Account $result */
$result = auth()->user()->accounts()->whereIn('account_type_id', $accountTypeIds)->where('id', '!=', $ignore)
->where('name', $value)
->first()
;
$result = auth()->user()->accounts()->whereIn('account_type_id', $accountTypeIds)->where('id', '!=', $ignore)
->where('name', $value)
->first();
return null === $result;
}
@ -536,13 +551,12 @@ class FireflyValidator extends Validator
private function validateByAccountTypeId($value, $parameters): bool
{
$type = AccountType::find($this->data['account_type_id'])->first();
$ignore = (int)($parameters[0] ?? 0.0);
$ignore = (int) ($parameters[0] ?? 0.0);
/** @var null|Account $result */
$result = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value)
->first()
;
->where('name', $value)
->first();
return null === $result;
}
@ -555,13 +569,12 @@ class FireflyValidator extends Validator
/** @var Account $existingAccount */
$existingAccount = Account::find($accountId);
$type = $existingAccount->accountType;
$ignore = $existingAccount->id;
$type = $existingAccount->accountType;
$ignore = $existingAccount->id;
$entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value)
->first()
;
$entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value)
->first();
return null === $entry;
}
@ -574,13 +587,12 @@ class FireflyValidator extends Validator
/** @var Account $existingAccount */
$existingAccount = Account::find($this->data['id']);
$type = $existingAccount->accountType;
$ignore = $existingAccount->id;
$type = $existingAccount->accountType;
$ignore = $existingAccount->id;
$entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value)
->first()
;
$entry = auth()->user()->accounts()->where('account_type_id', $type->id)->where('id', '!=', $ignore)
->where('name', $value)
->first();
return null === $entry;
}
@ -599,24 +611,23 @@ class FireflyValidator extends Validator
*/
public function validateUniqueAccountNumberForUser($attribute, $value, $parameters): bool
{
$accountId = (int)($this->data['id'] ?? 0.0);
$accountId = (int) ($this->data['id'] ?? 0.0);
if (0 === $accountId) {
$accountId = (int)($parameters[0] ?? 0.0);
$accountId = (int) ($parameters[0] ?? 0.0);
}
$query = AccountMeta::leftJoin('accounts', 'accounts.id', '=', 'account_meta.account_id')
->whereNull('accounts.deleted_at')
->where('accounts.user_id', auth()->user()->id)
->where('account_meta.name', 'account_number')
->where('account_meta.data', json_encode($value))
;
$query = AccountMeta::leftJoin('accounts', 'accounts.id', '=', 'account_meta.account_id')
->whereNull('accounts.deleted_at')
->where('accounts.user_id', auth()->user()->id)
->where('account_meta.name', 'account_number')
->where('account_meta.data', json_encode($value));
if ($accountId > 0) {
// exclude current account from check.
$query->where('account_meta.account_id', '!=', $accountId);
}
$set = $query->get(['account_meta.*']);
$count = $set->count();
$set = $query->get(['account_meta.*']);
$count = $set->count();
if (0 === $count) {
return true;
}
@ -624,7 +635,7 @@ class FireflyValidator extends Validator
// pretty much impossible but still.
return false;
}
$type = $this->data['objectType'] ?? 'unknown';
$type = $this->data['objectType'] ?? 'unknown';
if ('expense' !== $type && 'revenue' !== $type) {
app('log')->warning(sprintf('Account number "%s" is not unique and account type "%s" cannot share its account number.', $value, $type));
@ -636,7 +647,7 @@ class FireflyValidator extends Validator
/** @var AccountMeta $entry */
foreach ($set as $entry) {
$otherAccount = $entry->account;
$otherType = (string)config(sprintf('firefly.shortNamesByFullName.%s', $otherAccount->accountType->type));
$otherType = (string) config(sprintf('firefly.shortNamesByFullName.%s', $otherAccount->accountType->type));
if (('expense' === $otherType || 'revenue' === $otherType) && $otherType !== $type) {
app('log')->debug(sprintf('The other account with this account number is a "%s" so return true.', $otherType));
@ -653,7 +664,7 @@ class FireflyValidator extends Validator
*/
public function validateUniqueCurrencyCode(?string $attribute, ?string $value): bool
{
return $this->validateUniqueCurrency('code', (string)$attribute, (string)$value);
return $this->validateUniqueCurrency('code', (string) $attribute, (string) $value);
}
/**
@ -666,12 +677,12 @@ class FireflyValidator extends Validator
public function validateUniqueCurrencyName(?string $attribute, ?string $value): bool
{
return $this->validateUniqueCurrency('name', (string)$attribute, (string)$value);
return $this->validateUniqueCurrency('name', (string) $attribute, (string) $value);
}
public function validateUniqueCurrencySymbol(?string $attribute, ?string $value): bool
{
return $this->validateUniqueCurrency('symbol', (string)$attribute, (string)$value);
return $this->validateUniqueCurrency('symbol', (string) $attribute, (string) $value);
}
/**
@ -683,7 +694,7 @@ class FireflyValidator extends Validator
*/
public function validateUniqueExistingWebhook($value, $parameters, $something): bool
{
$existingId = (int)($something[0] ?? 0);
$existingId = (int) ($something[0] ?? 0);
$trigger = 0;
$response = 0;
$delivery = 0;
@ -694,7 +705,7 @@ class FireflyValidator extends Validator
// get existing webhook value:
if (0 !== $existingId) {
/** @var null|Webhook $webhook */
$webhook = auth()->user()->webhooks()->find($existingId);
$webhook = auth()->user()->webhooks()->find($existingId);
if (null === $webhook) {
return false;
}
@ -712,12 +723,11 @@ class FireflyValidator extends Validator
$userId = auth()->user()->id;
return 0 === Webhook::whereUserId($userId)
->where('trigger', $trigger)
->where('response', $response)
->where('delivery', $delivery)
->where('id', '!=', $existingId)
->where('url', $url)->count()
;
->where('trigger', $trigger)
->where('response', $response)
->where('delivery', $delivery)
->where('id', '!=', $existingId)
->where('url', $url)->count();
}
return false;
@ -739,22 +749,21 @@ class FireflyValidator extends Validator
public function validateUniqueObjectForUser($attribute, $value, $parameters): bool
{
[$table, $field] = $parameters;
$exclude = (int)($parameters[2] ?? 0.0);
$exclude = (int) ($parameters[2] ?? 0.0);
/*
* If other data (in $this->getData()) contains
* ID field, set that field to be the $exclude.
*/
$data = $this->getData();
if (!array_key_exists(2, $parameters) && array_key_exists('id', $data) && (int)$data['id'] > 0) {
$exclude = (int)$data['id'];
$data = $this->getData();
if (!array_key_exists(2, $parameters) && array_key_exists('id', $data) && (int) $data['id'] > 0) {
$exclude = (int) $data['id'];
}
// get entries from table
$result = \DB::table($table)->where('user_id', auth()->user()->id)->whereNull('deleted_at')
->where('id', '!=', $exclude)
->where($field, $value)
->first([$field])
;
$result = \DB::table($table)->where('user_id', auth()->user()->id)->whereNull('deleted_at')
->where('id', '!=', $exclude)
->where($field, $value)
->first([$field]);
if (null === $result) {
return true; // not found, so true.
}
@ -774,12 +783,11 @@ class FireflyValidator extends Validator
{
$exclude = $parameters[0] ?? null;
$query = \DB::table('object_groups')
->whereNull('object_groups.deleted_at')
->where('object_groups.user_id', auth()->user()->id)
->where('object_groups.title', $value)
;
->whereNull('object_groups.deleted_at')
->where('object_groups.user_id', auth()->user()->id)
->where('object_groups.title', $value);
if (null !== $exclude) {
$query->where('object_groups.id', '!=', (int)$exclude);
$query->where('object_groups.id', '!=', (int) $exclude);
}
return 0 === $query->count();
@ -796,10 +804,9 @@ class FireflyValidator extends Validator
{
$exclude = $parameters[0] ?? null;
$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) {
$query->where('piggy_banks.id', '!=', (int)$exclude);
$query->where('piggy_banks.id', '!=', (int) $exclude);
}
$query->where('piggy_banks.name', $value);
@ -820,18 +827,17 @@ class FireflyValidator extends Validator
$deliveries = Webhook::getDeliveriesForValidation();
// integers
$trigger = $triggers[$this->data['trigger']] ?? 0;
$response = $responses[$this->data['response']] ?? 0;
$delivery = $deliveries[$this->data['delivery']] ?? 0;
$url = $this->data['url'];
$userId = auth()->user()->id;
$trigger = $triggers[$this->data['trigger']] ?? 0;
$response = $responses[$this->data['response']] ?? 0;
$delivery = $deliveries[$this->data['delivery']] ?? 0;
$url = $this->data['url'];
$userId = auth()->user()->id;
return 0 === Webhook::whereUserId($userId)
->where('trigger', $trigger)
->where('response', $response)
->where('delivery', $delivery)
->where('url', $url)->count()
;
->where('trigger', $trigger)
->where('response', $response)
->where('delivery', $delivery)
->where('url', $url)->count();
}
return false;

View File

@ -80,4 +80,10 @@ return [
'revenue_accounts' => 'Revenue accounts',
'liabilities_accounts' => 'Liabilities',
'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_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.',
// 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

View File

@ -1320,18 +1320,18 @@ return [
'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_fiscal_year_start_label' => 'Fiscal year start date',
'pref_two_factor_auth' => '2-step verification',
'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_enable_two_factor_auth' => 'Enable 2-step verification',
'pref_two_factor_auth_disabled' => '2-step verification code removed and disabled',
'pref_two_factor_auth' => 'Multi-factor authentication',
'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 multi-factor authentication',
'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_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_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_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.',
'pref_save_settings' => 'Save settings',
'saved_preferences' => 'Preferences saved!',
@ -1413,7 +1413,27 @@ return [
'administration_role_view_reports' => 'View reports',
'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:
'manage_mfa_settings' => 'Manage multi-factor authentication settings',
'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.',
'delete_stuff_header' => 'Delete and purge data',
@ -1471,6 +1491,7 @@ return [
'delete_your_account_password' => 'Enter your password to continue.',
'password' => 'Password',
'are_you_sure' => 'Are you sure? You cannot undo this.',
'are_you_sure_confirm' => 'Are you sure?',
'delete_account_button' => 'DELETE your account',
'invalid_current_password' => 'Invalid current password!',
'password_changed' => 'Password changed!',

View File

@ -25,162 +25,162 @@
declare(strict_types=1);
return [
'filter_must_be_in' => 'Filter ":filter" must be one of: :values',
'filter_not_string' => 'Filter ":filter" is expected to be a string of text',
'bad_api_filter' => 'This API endpoint does not support ":filter" as a filter.',
'nog_logged_in' => 'You are not logged in.',
'bad_type_source' => 'Firefly III can\'t determine the transaction type based on this source account.',
'bad_type_destination' => 'Firefly III can\'t determine the transaction type based on this destination account.',
'missing_where' => 'Array is missing "where"-clause',
'missing_update' => 'Array is missing "update"-clause',
'invalid_where_key' => 'JSON contains an invalid key for the "where"-clause',
'invalid_update_key' => 'JSON contains an invalid key for the "update"-clause',
'invalid_query_data' => 'There is invalid data in the %s:%s field of your query.',
'invalid_query_account_type' => 'Your query contains accounts of different types, which is not allowed.',
'invalid_query_currency' => 'Your query contains accounts that have different currency settings, which is not allowed.',
'iban' => 'This is not a valid IBAN.',
'zero_or_more' => 'The value cannot be negative.',
'more_than_zero' => 'The value must be more than zero.',
'more_than_zero_correct' => 'The value must be zero or more.',
'no_asset_account' => 'This is not an asset account.',
'date_or_time' => 'The value must be a valid date or time value (ISO 8601).',
'source_equals_destination' => 'The source account equals the destination account.',
'unique_account_number_for_user' => 'It looks like this account number is already in use.',
'unique_iban_for_user' => 'It looks like this IBAN is already in use.',
'reconciled_forbidden_field' => 'This transaction is already reconciled, you cannot change the ":field"',
'deleted_user' => 'Due to security constraints, you cannot register using this email address.',
'rule_trigger_value' => 'This value is invalid for the selected trigger.',
'rule_action_expression' => 'Invalid expression. :error',
'rule_action_value' => 'This value is invalid for the selected action.',
'file_already_attached' => 'Uploaded file ":name" is already attached to this object.',
'file_attached' => 'Successfully uploaded file ":name".',
'file_zero' => 'The file is zero bytes in size.',
'must_exist' => 'The ID in field :attribute does not exist in the database.',
'all_accounts_equal' => 'All accounts in this field must be equal.',
'group_title_mandatory' => 'A group title is mandatory when there is more than one transaction.',
'transaction_types_equal' => 'All splits must be of the same type.',
'invalid_transaction_type' => 'Invalid transaction type.',
'invalid_selection' => 'Your selection is invalid.',
'belongs_user' => 'This value is linked to an object that does not seem to exist.',
'belongs_user_or_user_group' => 'This value is linked to an object that does not seem to exist in your current financial administration.',
'no_access_group' => 'The user has no access to this user group.',
'no_accepted_roles_defined' => 'No access roles have been defined for this endpoint, access denied.',
'at_least_one_transaction' => 'Need at least one transaction.',
'recurring_transaction_id' => 'Need at least one transaction.',
'need_id_to_match' => 'You need to submit this entry with an ID for the API to be able to match it.',
'too_many_unmatched' => 'Too many submitted transactions cannot be matched to their respective database entries. Make sure existing entries have a valid ID.',
'id_does_not_match' => 'Submitted ID #:id does not match expected ID. Make sure it matches or omit the field.',
'at_least_one_repetition' => 'Need at least one repetition.',
'require_repeat_until' => 'Require either a number of repetitions, or an end date (repeat_until). Not both.',
'require_currency_info' => 'The content of this field is invalid without currency information.',
'not_transfer_account' => 'This account is not an account that can be used for transfers.',
'require_currency_amount' => 'The content of this field is invalid without foreign amount information.',
'require_foreign_currency' => 'This field requires a number',
'require_foreign_dest' => 'This field value must match the currency of the destination account.',
'require_foreign_src' => 'This field value must match the currency of the source account.',
'equal_description' => 'Transaction description should not equal global description.',
'file_invalid_mime' => 'File ":name" is of type ":mime" which is not accepted as a new upload.',
'file_too_large' => 'File ":name" is too large.',
'belongs_to_user' => 'The value of :attribute is unknown.',
'accepted' => 'The :attribute must be accepted.',
'bic' => 'This is not a valid BIC.',
'at_least_one_trigger' => 'Rule must have at least one trigger.',
'at_least_one_active_trigger' => 'Rule must have at least one active trigger.',
'at_least_one_action' => 'Rule must have at least one action.',
'at_least_one_active_action' => 'Rule must have at least one active action.',
'base64' => 'This is not valid base64 encoded data.',
'model_id_invalid' => 'The given ID seems invalid for this model.',
'less' => ':attribute must be less than 10,000,000',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'date_after' => 'The start date must be before the end date.',
'alpha' => 'The :attribute may only contain letters.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.',
'alpha_num' => 'The :attribute may only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'unique_for_user' => 'There already is an entry with this :attribute.',
'before' => 'The :attribute must be a date before :date.',
'unique_object_for_user' => 'This name is already in use.',
'unique_account_for_user' => 'This account name is already in use.',
'filter_must_be_in' => 'Filter ":filter" must be one of: :values',
'filter_not_string' => 'Filter ":filter" is expected to be a string of text',
'bad_api_filter' => 'This API endpoint does not support ":filter" as a filter.',
'nog_logged_in' => 'You are not logged in.',
'bad_type_source' => 'Firefly III can\'t determine the transaction type based on this source account.',
'bad_type_destination' => 'Firefly III can\'t determine the transaction type based on this destination account.',
'missing_where' => 'Array is missing "where"-clause',
'missing_update' => 'Array is missing "update"-clause',
'invalid_where_key' => 'JSON contains an invalid key for the "where"-clause',
'invalid_update_key' => 'JSON contains an invalid key for the "update"-clause',
'invalid_query_data' => 'There is invalid data in the %s:%s field of your query.',
'invalid_query_account_type' => 'Your query contains accounts of different types, which is not allowed.',
'invalid_query_currency' => 'Your query contains accounts that have different currency settings, which is not allowed.',
'iban' => 'This is not a valid IBAN.',
'zero_or_more' => 'The value cannot be negative.',
'more_than_zero' => 'The value must be more than zero.',
'more_than_zero_correct' => 'The value must be zero or more.',
'no_asset_account' => 'This is not an asset account.',
'date_or_time' => 'The value must be a valid date or time value (ISO 8601).',
'source_equals_destination' => 'The source account equals the destination account.',
'unique_account_number_for_user' => 'It looks like this account number is already in use.',
'unique_iban_for_user' => 'It looks like this IBAN is already in use.',
'reconciled_forbidden_field' => 'This transaction is already reconciled, you cannot change the ":field"',
'deleted_user' => 'Due to security constraints, you cannot register using this email address.',
'rule_trigger_value' => 'This value is invalid for the selected trigger.',
'rule_action_expression' => 'Invalid expression. :error',
'rule_action_value' => 'This value is invalid for the selected action.',
'file_already_attached' => 'Uploaded file ":name" is already attached to this object.',
'file_attached' => 'Successfully uploaded file ":name".',
'file_zero' => 'The file is zero bytes in size.',
'must_exist' => 'The ID in field :attribute does not exist in the database.',
'all_accounts_equal' => 'All accounts in this field must be equal.',
'group_title_mandatory' => 'A group title is mandatory when there is more than one transaction.',
'transaction_types_equal' => 'All splits must be of the same type.',
'invalid_transaction_type' => 'Invalid transaction type.',
'invalid_selection' => 'Your selection is invalid.',
'belongs_user' => 'This value is linked to an object that does not seem to exist.',
'belongs_user_or_user_group' => 'This value is linked to an object that does not seem to exist in your current financial administration.',
'no_access_group' => 'The user has no access to this user group.',
'no_accepted_roles_defined' => 'No access roles have been defined for this endpoint, access denied.',
'at_least_one_transaction' => 'Need at least one transaction.',
'recurring_transaction_id' => 'Need at least one transaction.',
'need_id_to_match' => 'You need to submit this entry with an ID for the API to be able to match it.',
'too_many_unmatched' => 'Too many submitted transactions cannot be matched to their respective database entries. Make sure existing entries have a valid ID.',
'id_does_not_match' => 'Submitted ID #:id does not match expected ID. Make sure it matches or omit the field.',
'at_least_one_repetition' => 'Need at least one repetition.',
'require_repeat_until' => 'Require either a number of repetitions, or an end date (repeat_until). Not both.',
'require_currency_info' => 'The content of this field is invalid without currency information.',
'not_transfer_account' => 'This account is not an account that can be used for transfers.',
'require_currency_amount' => 'The content of this field is invalid without foreign amount information.',
'require_foreign_currency' => 'This field requires a number',
'require_foreign_dest' => 'This field value must match the currency of the destination account.',
'require_foreign_src' => 'This field value must match the currency of the source account.',
'equal_description' => 'Transaction description should not equal global description.',
'file_invalid_mime' => 'File ":name" is of type ":mime" which is not accepted as a new upload.',
'file_too_large' => 'File ":name" is too large.',
'belongs_to_user' => 'The value of :attribute is unknown.',
'accepted' => 'The :attribute must be accepted.',
'bic' => 'This is not a valid BIC.',
'at_least_one_trigger' => 'Rule must have at least one trigger.',
'at_least_one_active_trigger' => 'Rule must have at least one active trigger.',
'at_least_one_action' => 'Rule must have at least one action.',
'at_least_one_active_action' => 'Rule must have at least one active action.',
'base64' => 'This is not valid base64 encoded data.',
'model_id_invalid' => 'The given ID seems invalid for this model.',
'less' => ':attribute must be less than 10,000,000',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'date_after' => 'The start date must be before the end date.',
'alpha' => 'The :attribute may only contain letters.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.',
'alpha_num' => 'The :attribute may only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'unique_for_user' => 'There already is an entry with this :attribute.',
'before' => 'The :attribute must be a date before :date.',
'unique_object_for_user' => 'This name is already in use.',
'unique_account_for_user' => 'This account name is already in use.',
// Ignore this comment
'between.numeric' => 'The :attribute must be between :min and :max.',
'between.file' => 'The :attribute must be between :min and :max kilobytes.',
'between.string' => 'The :attribute must be between :min and :max characters.',
'between.array' => 'The :attribute must have between :min and :max items.',
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute is not a valid date.',
'date_format' => 'The :attribute does not match the format :format.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'email' => 'The :attribute must be a valid email address.',
'filled' => 'The :attribute field is required.',
'exists' => 'The selected :attribute is invalid.',
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'json' => 'The :attribute must be a valid JSON string.',
'max.numeric' => 'The :attribute may not be greater than :max.',
'max.file' => 'The :attribute may not be greater than :max kilobytes.',
'max.string' => 'The :attribute may not be greater than :max characters.',
'max.array' => 'The :attribute may not have more than :max items.',
'mimes' => 'The :attribute must be a file of type: :values.',
'min.numeric' => 'The :attribute must be at least :min.',
'lte.numeric' => 'The :attribute must be less than or equal :value.',
'min.file' => 'The :attribute must be at least :min kilobytes.',
'min.string' => 'The :attribute must be at least :min characters.',
'min.array' => 'The :attribute must have at least :min items.',
'not_in' => 'The selected :attribute is invalid.',
'numeric' => 'The :attribute must be a number.',
'scientific_notation' => 'The :attribute cannot use the scientific notation.',
'numeric_native' => 'The native amount must be a number.',
'numeric_destination' => 'The destination amount must be a number.',
'numeric_source' => 'The source amount must be a number.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size.numeric' => 'The :attribute must be :size.',
'amount_min_over_max' => 'The minimum amount cannot be larger than the maximum amount.',
'size.file' => 'The :attribute must be :size kilobytes.',
'size.string' => 'The :attribute must be :size characters.',
'size.array' => 'The :attribute must contain :size items.',
'unique' => 'The :attribute has already been taken.',
'string' => 'The :attribute must be a string.',
'url' => 'The :attribute format is invalid.',
'timezone' => 'The :attribute must be a valid zone.',
'2fa_code' => 'The :attribute field is invalid.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'file' => 'The :attribute must be a file.',
'in_array' => 'The :attribute field does not exist in :other.',
'present' => 'The :attribute field must be present.',
'amount_zero' => 'The total amount cannot be zero.',
'current_target_amount' => 'The current amount must be less than the target amount.',
'unique_piggy_bank_for_user' => 'The name of the piggy bank must be unique.',
'unique_object_group' => 'The group name must be unique',
'starts_with' => 'The value must start with :values.',
'unique_webhook' => 'You already have a webhook with this combination of URL, trigger, response and delivery.',
'unique_existing_webhook' => 'You already have another webhook with this combination of URL, trigger, response and delivery.',
'same_account_type' => 'Both accounts must be of the same account type',
'same_account_currency' => 'Both accounts must have the same currency setting',
'between.numeric' => 'The :attribute must be between :min and :max.',
'between.file' => 'The :attribute must be between :min and :max kilobytes.',
'between.string' => 'The :attribute must be between :min and :max characters.',
'between.array' => 'The :attribute must have between :min and :max items.',
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute is not a valid date.',
'date_format' => 'The :attribute does not match the format :format.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'email' => 'The :attribute must be a valid email address.',
'filled' => 'The :attribute field is required.',
'exists' => 'The selected :attribute is invalid.',
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'json' => 'The :attribute must be a valid JSON string.',
'max.numeric' => 'The :attribute may not be greater than :max.',
'max.file' => 'The :attribute may not be greater than :max kilobytes.',
'max.string' => 'The :attribute may not be greater than :max characters.',
'max.array' => 'The :attribute may not have more than :max items.',
'mimes' => 'The :attribute must be a file of type: :values.',
'min.numeric' => 'The :attribute must be at least :min.',
'lte.numeric' => 'The :attribute must be less than or equal :value.',
'min.file' => 'The :attribute must be at least :min kilobytes.',
'min.string' => 'The :attribute must be at least :min characters.',
'min.array' => 'The :attribute must have at least :min items.',
'not_in' => 'The selected :attribute is invalid.',
'numeric' => 'The :attribute must be a number.',
'scientific_notation' => 'The :attribute cannot use the scientific notation.',
'numeric_native' => 'The native amount must be a number.',
'numeric_destination' => 'The destination amount must be a number.',
'numeric_source' => 'The source amount must be a number.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size.numeric' => 'The :attribute must be :size.',
'amount_min_over_max' => 'The minimum amount cannot be larger than the maximum amount.',
'size.file' => 'The :attribute must be :size kilobytes.',
'size.string' => 'The :attribute must be :size characters.',
'size.array' => 'The :attribute must contain :size items.',
'unique' => 'The :attribute has already been taken.',
'string' => 'The :attribute must be a string.',
'url' => 'The :attribute format is invalid.',
'timezone' => 'The :attribute must be a valid zone.',
'2fa_code' => 'The :attribute field is invalid.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'file' => 'The :attribute must be a file.',
'in_array' => 'The :attribute field does not exist in :other.',
'present' => 'The :attribute field must be present.',
'amount_zero' => 'The total amount cannot be zero.',
'current_target_amount' => 'The current amount must be less than the target amount.',
'unique_piggy_bank_for_user' => 'The name of the piggy bank must be unique.',
'unique_object_group' => 'The group name must be unique',
'starts_with' => 'The value must start with :values.',
'unique_webhook' => 'You already have a webhook with this combination of URL, trigger, response and delivery.',
'unique_existing_webhook' => 'You already have another webhook with this combination of URL, trigger, response and delivery.',
'same_account_type' => 'Both accounts must be of the same account type',
'same_account_currency' => 'Both accounts must have the same currency setting',
// Ignore this comment
'secure_password' => 'This is not a secure password. Please try again. For more information, visit https://bit.ly/FF3-password',
'valid_recurrence_rep_type' => 'Invalid repetition type for recurring transactions.',
'valid_recurrence_rep_moment' => 'Invalid repetition moment for this type of repetition.',
'invalid_account_info' => 'Invalid account information.',
'attributes' => [
'secure_password' => 'This is not a secure password. Please try again. For more information, visit https://bit.ly/FF3-password',
'valid_recurrence_rep_type' => 'Invalid repetition type for recurring transactions.',
'valid_recurrence_rep_moment' => 'Invalid repetition moment for this type of repetition.',
'invalid_account_info' => 'Invalid account information.',
'attributes' => [
'email' => 'email address',
'description' => 'description',
'amount' => 'amount',
@ -219,59 +219,60 @@ return [
],
// validation of accounts:
'withdrawal_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'withdrawal_source_bad_data' => '[a] Could not find a valid source account when searching for ID ":id" or name ":name".',
'withdrawal_dest_need_data' => '[a] Need to get a valid destination account ID and/or valid destination account name to continue.',
'withdrawal_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'withdrawal_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'withdrawal_source_bad_data' => '[a] Could not find a valid source account when searching for ID ":id" or name ":name".',
'withdrawal_dest_need_data' => '[a] Need to get a valid destination account ID and/or valid destination account name to continue.',
'withdrawal_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'withdrawal_dest_iban_exists' => 'This destination account IBAN is already in use by an asset account or a liability and cannot be used as a withdrawal destination.',
'deposit_src_iban_exists' => 'This source account IBAN is already in use by an asset account or a liability and cannot be used as a deposit source.',
'withdrawal_dest_iban_exists' => 'This destination account IBAN is already in use by an asset account or a liability and cannot be used as a withdrawal destination.',
'deposit_src_iban_exists' => 'This source account IBAN is already in use by an asset account or a liability and cannot be used as a deposit source.',
'reconciliation_source_bad_data' => 'Could not find a valid reconciliation account when searching for ID ":id" or name ":name".',
'reconciliation_source_bad_data' => 'Could not find a valid reconciliation account when searching for ID ":id" or name ":name".',
'generic_source_bad_data' => '[e] Could not find a valid source account when searching for ID ":id" or name ":name".',
'generic_source_bad_data' => '[e] Could not find a valid source account when searching for ID ":id" or name ":name".',
'deposit_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'deposit_source_bad_data' => '[b] Could not find a valid source account when searching for ID ":id" or name ":name".',
'deposit_dest_need_data' => '[b] Need to get a valid destination account ID and/or valid destination account name to continue.',
'deposit_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'deposit_dest_wrong_type' => 'The submitted destination account is not of the right type.',
'deposit_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'deposit_source_bad_data' => '[b] Could not find a valid source account when searching for ID ":id" or name ":name".',
'deposit_dest_need_data' => '[b] Need to get a valid destination account ID and/or valid destination account name to continue.',
'deposit_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'deposit_dest_wrong_type' => 'The submitted destination account is not of the right type.',
// Ignore this comment
'transfer_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'transfer_source_bad_data' => '[c] Could not find a valid source account when searching for ID ":id" or name ":name".',
'transfer_dest_need_data' => '[c] Need to get a valid destination account ID and/or valid destination account name to continue.',
'transfer_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'need_id_in_edit' => 'Each split must have transaction_journal_id (either valid ID or 0).',
'transfer_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'transfer_source_bad_data' => '[c] Could not find a valid source account when searching for ID ":id" or name ":name".',
'transfer_dest_need_data' => '[c] Need to get a valid destination account ID and/or valid destination account name to continue.',
'transfer_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'need_id_in_edit' => 'Each split must have transaction_journal_id (either valid ID or 0).',
'ob_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'lc_source_need_data' => 'Need to get a valid source account ID to continue.',
'ob_dest_need_data' => '[d] Need to get a valid destination account ID and/or valid destination account name to continue.',
'ob_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'reconciliation_either_account' => 'To submit a reconciliation, you must submit either a source or a destination account. Not both, not neither.',
'ob_source_need_data' => 'Need to get a valid source account ID and/or valid source account name to continue.',
'lc_source_need_data' => 'Need to get a valid source account ID to continue.',
'ob_dest_need_data' => '[d] Need to get a valid destination account ID and/or valid destination account name to continue.',
'ob_dest_bad_data' => 'Could not find a valid destination account when searching for ID ":id" or name ":name".',
'reconciliation_either_account' => 'To submit a reconciliation, you must submit either a source or a destination account. Not both, not neither.',
'generic_invalid_source' => 'You can\'t use this account as the source account.',
'generic_invalid_destination' => 'You can\'t use this account as the destination account.',
'generic_invalid_source' => 'You can\'t use this account as the source account.',
'generic_invalid_destination' => 'You can\'t use this account as the destination account.',
'generic_no_source' => 'You must submit source account information or submit a transaction journal ID.',
'generic_no_destination' => 'You must submit destination account information or submit a transaction journal ID.',
'generic_no_source' => 'You must submit source account information or submit a transaction journal ID.',
'generic_no_destination' => 'You must submit destination account information or submit a transaction journal ID.',
'gte.numeric' => 'The :attribute must be greater than or equal to :value.',
'gt.numeric' => 'The :attribute must be greater than :value.',
'gte.file' => 'The :attribute must be greater than or equal to :value kilobytes.',
'gte.string' => 'The :attribute must be greater than or equal to :value characters.',
'gte.array' => 'The :attribute must have :value items or more.',
'gte.numeric' => 'The :attribute must be greater than or equal to :value.',
'gt.numeric' => 'The :attribute must be greater than :value.',
'gte.file' => 'The :attribute must be greater than or equal to :value kilobytes.',
'gte.string' => 'The :attribute must be greater than or equal to :value characters.',
'gte.array' => 'The :attribute must have :value items or more.',
'amount_required_for_auto_budget' => 'The amount is required.',
'auto_budget_amount_positive' => 'The amount must be more than zero.',
'auto_budget_period_mandatory' => 'The auto budget period is a mandatory field.',
'auto_budget_period_mandatory' => 'The auto budget period is a mandatory field.',
// no access to 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.',
'administration_owner_rename' => 'You can\'t rename your standard 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.',
'administration_owner_rename' => 'You can\'t rename your standard administration.',
'existing_mfa_code' => 'Please enter a valid code',
];
// 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">
<a href="#oauth" aria-controls="messages" role="tab" data-toggle="tab">{{ 'oauth'|_ }}</a>
</li>
{#
{% if true == isInternalAuth %}
<li role="presentation">
<a href="#mfa" aria-controls="settings" role="tab"
data-toggle="tab">{{ 'pref_two_factor_auth'|_ }}</a>
</li>
{% endif %}
#}
<li role="presentation">
<a href="#delete" aria-controls="settings" role="tab"
data-toggle="tab">{{ 'delete_stuff_header'|_ }}</a>
@ -52,6 +54,16 @@
<li>
<a href="{{ route('profile.change-password') }}">{{ 'change_your_password'|_ }}</a>
</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 %}
<li><a href="{{ route('logout') }}" class="logout-link">{{ 'logout'|_ }}</a>
@ -103,6 +115,7 @@
<div id="passport_clients"></div>
</div>
{#
{% if true == isInternalAuth %}
<!-- MFA -->
<div role="tabpanel" class="tab-pane" id="mfa">
@ -141,6 +154,7 @@
</div>
</div>
{% endif %}
#}
<!-- purge stuff -->
<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 %}
{% 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() }}">
<div class="row">
<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>
</div>
<div class="box-body">
<p class="text-info hidden-print">
<p class="hidden-print">
{{ 'pref_two_factor_auth_code_help'|_ }}
</p>
<div class="form group">
@ -24,10 +24,9 @@
<p class="hidden-print">
{{ trans('firefly.2fa_use_secret_instead', {secret: secret|escape})|raw }}
</p>
<p>
{{ '2fa_backup_codes'|_ }}
<p class="hidden-print text-danger">
{{ 'mfa_warning_code_changes'|_ }}
</p>
<pre>{{ codes }}</pre>
</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="box">
<div class="box-body">
{{ ExpandedForm.password('password', {helpText: 'current_password_confirm_mfa'|_}) }}
{{ ExpandedForm.text('code', code) }}
</div>
<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(
'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
Breadcrumbs::for(
'profile.index',

View File

@ -825,15 +825,35 @@ Route::group(
Route::get('logout-others', ['uses' => 'ProfileController@logoutOtherSessions', 'as' => 'logout-others']);
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.
Route::group(
['middleware' => 'user-full-auth', 'namespace' => 'FireflyIII\Http\Controllers', 'prefix' => 'recurring', 'as' => 'recurring.'],