diff --git a/app/Events/Security/MFAManyFailedAttempts.php b/app/Events/Security/MFAManyFailedAttempts.php new file mode 100644 index 0000000000..57dc83c37d --- /dev/null +++ b/app/Events/Security/MFAManyFailedAttempts.php @@ -0,0 +1,45 @@ +user = $user; + } + $this->count = $count; + } +} diff --git a/app/Handlers/Events/Security/MFAHandler.php b/app/Handlers/Events/Security/MFAHandler.php index 4092eacfa1..db3b162ecf 100644 --- a/app/Handlers/Events/Security/MFAHandler.php +++ b/app/Handlers/Events/Security/MFAHandler.php @@ -27,12 +27,14 @@ use FireflyIII\Events\Security\DisabledMFA; use FireflyIII\Events\Security\EnabledMFA; use FireflyIII\Events\Security\MFABackupFewLeft; use FireflyIII\Events\Security\MFABackupNoLeft; +use FireflyIII\Events\Security\MFAManyFailedAttempts; 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\MFAManyFailedAttemptsNotification; use FireflyIII\Notifications\Security\MFAUsedBackupCodeNotification; use FireflyIII\Notifications\Security\NewBackupCodesNotification; use Illuminate\Support\Facades\Notification; @@ -115,6 +117,32 @@ class MFAHandler } } + public function sendMFAFailedAttemptsMail(MFAManyFailedAttempts $event): void + { + app('log')->debug(sprintf('Now in %s', __METHOD__)); + + $user = $event->user; + $count = $event->count; + + try { + Notification::send($user, new MFAManyFailedAttemptsNotification($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 { diff --git a/app/Http/Controllers/Auth/TwoFactorController.php b/app/Http/Controllers/Auth/TwoFactorController.php index 733d68a55e..293935744f 100644 --- a/app/Http/Controllers/Auth/TwoFactorController.php +++ b/app/Http/Controllers/Auth/TwoFactorController.php @@ -25,6 +25,7 @@ namespace FireflyIII\Http\Controllers\Auth; use FireflyIII\Events\Security\MFABackupFewLeft; use FireflyIII\Events\Security\MFABackupNoLeft; +use FireflyIII\Events\Security\MFAManyFailedAttempts; use FireflyIII\Events\Security\MFAUsedBackupCode; use FireflyIII\Http\Controllers\Controller; use FireflyIII\User; @@ -76,10 +77,26 @@ class TwoFactorController extends Controller /** @var Authenticator $authenticator */ $authenticator = app(Authenticator::class)->boot($request); + // if not OK, save error. + if (!$authenticator->isAuthenticated()) { + $user = auth()->user(); + $this->addToMFAFailureCounter(); + $counter = $this->getMFAFailureCounter(); + if (3 === $counter || 10 === $counter) { + // do not reset MFA failure counter, but DO send a warning to the user. + Log::channel('audit')->info(sprintf('User "%s" has had %d failed MFA attempts.', $user->email, $counter)); + event(new MFAManyFailedAttempts($user, $counter)); + } + unset($user); + } + if ($authenticator->isAuthenticated()) { // save MFA in preferences $this->addToMFAHistory($mfaCode); + // reset failure count + $this->resetMFAFailureCounter(); + // otp auth success! return redirect(route('home')); } @@ -89,6 +106,9 @@ class TwoFactorController extends Controller $this->removeFromBackupCodes($mfaCode); $authenticator->login(); + // reset failure count + $this->resetMFAFailureCounter(); + session()->flash('info', trans('firefly.mfa_backup_code')); // send user notification. $user = auth()->user(); @@ -185,13 +205,13 @@ class TwoFactorController extends Controller $newList = array_values(array_diff($list, [$mfaCode])); // if the list is 3 or less, send a notification. - if(count($newList) <= 3 && count($newList) > 0) { + 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)) { + 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)); @@ -199,4 +219,25 @@ class TwoFactorController extends Controller app('preferences')->set('mfa_recovery', $newList); } + + private function addToMFAFailureCounter(): void + { + $preference = (int) app('preferences')->get('mfa_failure_count', 0)->data; + $preference++; + Log::channel('audit')->info(sprintf('MFA failure count is set to %d.', $preference)); + app('preferences')->set('mfa_failure_count', $preference); + } + + private function getMFAFailureCounter(): int + { + $value = (int) app('preferences')->get('mfa_failure_count', 0)->data; + Log::channel('audit')->info(sprintf('MFA failure count is %d.', $value)); + return $value; + } + + private function resetMFAFailureCounter(): void + { + app('preferences')->set('mfa_failure_count', 0); + Log::channel('audit')->info('MFA failure count is set to zero.'); + } } diff --git a/app/Notifications/Security/MFAManyFailedAttemptsNotification.php b/app/Notifications/Security/MFAManyFailedAttemptsNotification.php new file mode 100644 index 0000000000..29e527cdd2 --- /dev/null +++ b/app/Notifications/Security/MFAManyFailedAttemptsNotification.php @@ -0,0 +1,119 @@ +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_many_failed_subject', ['count' => $this->count]); + + return (new MailMessage())->markdown('emails.security.many-failed-attempts', ['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_many_failed_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']; + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index d68eaca73f..2a49dc8b21 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -44,6 +44,7 @@ use FireflyIII\Events\Security\DisabledMFA; use FireflyIII\Events\Security\EnabledMFA; use FireflyIII\Events\Security\MFABackupFewLeft; use FireflyIII\Events\Security\MFABackupNoLeft; +use FireflyIII\Events\Security\MFAManyFailedAttempts; use FireflyIII\Events\Security\MFANewBackupCodes; use FireflyIII\Events\Security\MFAUsedBackupCode; use FireflyIII\Events\StoredAccount; @@ -231,6 +232,9 @@ class EventServiceProvider extends ServiceProvider MFABackupNoLeft::class => [ 'FireflyIII\Handlers\Events\Security\MFAHandler@sendBackupNoLeftMail', ], + MFAManyFailedAttempts::class => [ + 'FireflyIII\Handlers\Events\Security\MFAHandler@sendMFAFailedAttemptsMail', + ], ]; /** diff --git a/resources/lang/en_US/email.php b/resources/lang/en_US/email.php index c6d617f569..9a8173662e 100644 --- a/resources/lang/en_US/email.php +++ b/resources/lang/en_US/email.php @@ -169,5 +169,11 @@ return [ '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.', + // many failed MFA attempts + 'mfa_many_failed_subject' => 'You have tried and failed to use multi-factor authentication :count times now!', + 'mfa_many_failed_slack' => 'You (:email) have tried and failed to use multi-factor authentication :count times now. Is this not correct? Check your settings!', + 'mfa_many_failed_attempts_intro' => 'You (:email) have tried :count times to use a multi-factor authentication code, but these login attempts have failed. Are you sure you are using the right MFA code? Are you sure the time on the server is correct?', + 'mfa_many_failed_attempts_warning' => 'If you did not do this, please contact your administrator immediately or check out the Firefly III documentation.', + ]; // Ignore this comment diff --git a/resources/views/emails/security/many-failed-attempts.blade.php b/resources/views/emails/security/many-failed-attempts.blade.php new file mode 100644 index 0000000000..709fe868eb --- /dev/null +++ b/resources/views/emails/security/many-failed-attempts.blade.php @@ -0,0 +1,6 @@ +@component('mail::message') +{{ trans('email.mfa_many_failed_attempts_intro', ['email' => $user->email, 'count' => $count]) }} + +{{ trans('email.mfa_many_failed_attempts_warning') }} + +@endcomponent