2024-10-08 07:21:23 +02:00
< ? php
2024-11-25 04:18:55 +01:00
2024-10-08 07:21:23 +02:00
/*
* 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 ;
2025-05-24 05:52:31 +02:00
use Carbon\Carbon ;
2026-01-19 19:36:21 +01:00
use FireflyIII\Events\Security\User\UserHasDisabledMFA ;
2026-01-19 19:43:06 +01:00
use FireflyIII\Events\Security\User\UserHasEnabledMFA ;
2026-01-19 20:08:15 +01:00
use FireflyIII\Events\Security\User\UserHasGeneratedNewBackupCodes ;
2024-10-08 07:21:23 +02:00
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 ;
2026-01-19 19:36:21 +01:00
use FireflyIII\Support\Facades\Preferences ;
2024-10-08 07:21:23 +02:00
use FireflyIII\User ;
use Illuminate\Contracts\View\Factory ;
use Illuminate\Http\RedirectResponse ;
use Illuminate\Http\Request ;
use Illuminate\Routing\Redirector ;
2025-10-05 12:59:43 +02:00
use Illuminate\Support\Facades\Cookie ;
2024-10-08 07:21:23 +02:00
use Illuminate\Support\Facades\Log ;
use Illuminate\View\View ;
2025-10-05 12:59:43 +02:00
use PragmaRX\Google2FALaravel\Facade as Google2FA ;
2024-10-08 07:21:23 +02:00
use PragmaRX\Recovery\Recovery ;
2025-10-05 12:57:58 +02:00
use Psr\Container\ContainerExceptionInterface ;
use Psr\Container\NotFoundExceptionInterface ;
2024-10-08 07:21:23 +02:00
/**
* 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 2 FA 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 ();
2026-01-23 15:09:50 +01:00
$this -> middleware ( static function ( $request , $next ) {
app ( 'view' ) -> share ( 'title' , ( string ) trans ( 'firefly.profile' ));
app ( 'view' ) -> share ( 'mainTitleIcon' , 'fa-user' );
2024-10-08 07:21:23 +02:00
2026-01-23 15:09:50 +01:00
return $next ( $request );
});
2026-01-23 15:14:29 +01:00
$authGuard = config ( 'firefly.authentication_guard' );
2024-10-08 07:21:23 +02:00
$this -> internalAuth = 'web' === $authGuard ;
2025-11-09 09:07:14 +01:00
Log :: debug ( sprintf ( 'ProfileController::__construct(). Authentication guard is "%s"' , $authGuard ));
2024-10-08 07:21:23 +02:00
$this -> middleware ( IsDemoUser :: class ) -> except ([ 'index' ]);
}
2026-01-19 20:23:36 +01:00
public function backupCodes ( Request $request ) : Factory | RedirectResponse | View
2024-10-08 07:21:23 +02:00
{
if ( ! $this -> internalAuth ) {
2024-12-22 08:43:12 +01:00
$request -> session () -> flash ( 'error' , trans ( 'firefly.external_user_mgt_disabled' ));
2024-10-08 07:21:23 +02:00
return redirect ( route ( 'profile.index' ));
}
2024-12-22 08:43:12 +01:00
$enabledMFA = null !== auth () -> user () -> mfa_secret ;
if ( false === $enabledMFA ) {
request () -> session () -> flash ( 'info' , trans ( 'firefly.mfa_not_enabled' ));
2024-10-08 07:21:23 +02:00
2024-12-22 08:43:12 +01:00
return redirect ( route ( 'profile.index' ));
}
return view ( 'profile.mfa.backup-codes-intro' );
}
2026-01-19 20:23:36 +01:00
public function backupCodesPost ( ExistingTokenFormRequest $request ) : Redirector | RedirectResponse | View
2024-12-22 08:43:12 +01:00
{
if ( ! $this -> internalAuth ) {
$request -> session () -> flash ( 'error' , trans ( 'firefly.external_user_mgt_disabled' ));
return redirect ( route ( 'profile.index' ));
}
2026-01-23 15:14:29 +01:00
$enabledMFA = null !== auth () -> user () -> mfa_secret ;
2024-12-22 08:43:12 +01:00
if ( false === $enabledMFA ) {
request () -> session () -> flash ( 'info' , trans ( 'firefly.mfa_not_enabled' ));
return redirect ( route ( 'profile.index' ));
}
// generate recovery codes:
$recovery = app ( Recovery :: class );
2026-01-23 15:09:50 +01:00
$recoveryCodes = $recovery -> lowercase () -> setCount ( 8 ) -> setBlocks ( 2 ) -> setChars ( 6 ) -> toArray (); // Generate 8 codes // Every code must have 2 blocks // Each block must have 6 chars
2024-12-22 08:43:12 +01:00
$codes = implode ( " \r \n " , $recoveryCodes );
2025-11-28 19:01:15 +01:00
Preferences :: set ( 'mfa_recovery' , $recoveryCodes );
Preferences :: mark ();
2024-12-22 08:43:12 +01:00
// send user notification.
2026-01-23 15:14:29 +01:00
$user = auth () -> user ();
2024-12-22 08:43:12 +01:00
Log :: channel ( 'audit' ) -> info ( sprintf ( 'User "%s" has generated new backup codes.' , $user -> email ));
2026-01-19 20:08:15 +01:00
event ( new UserHasGeneratedNewBackupCodes ( $user ));
2024-12-22 08:43:12 +01:00
2025-11-09 09:07:14 +01:00
return view ( 'profile.mfa.backup-codes-post' ) -> with ([ 'codes' => $codes ]);
2024-10-08 07:21:23 +02:00
}
2026-01-19 20:23:36 +01:00
public function disableMFA ( Request $request ) : Factory | RedirectResponse | View
2024-10-08 07:21:23 +02:00
{
if ( ! $this -> internalAuth ) {
request () -> session () -> flash ( 'error' , trans ( 'firefly.external_user_mgt_disabled' ));
return redirect ( route ( 'profile.index' ));
}
2026-01-23 15:14:29 +01:00
$enabledMFA = null !== auth () -> user () -> mfa_secret ;
2024-10-14 05:14:52 +02:00
if ( false === $enabledMFA ) {
request () -> session () -> flash ( 'info' , trans ( 'firefly.mfa_already_disabled' ));
2024-10-08 07:21:23 +02:00
return redirect ( route ( 'profile.index' ));
}
2026-01-23 15:09:50 +01:00
$subTitle = ( string ) trans ( 'firefly.mfa_index_title' );
2024-10-14 05:14:52 +02:00
$subTitleIcon = 'fa-calculator' ;
2024-10-08 07:21:23 +02:00
2026-01-23 15:09:50 +01:00
return view ( 'profile.mfa.disable-mfa' ) -> with ([ 'subTitle' => $subTitle , 'subTitleIcon' => $subTitleIcon , 'enabledMFA' => $enabledMFA ]);
2024-10-08 07:21:23 +02:00
}
/**
* Delete 2 FA routine .
*/
2026-01-19 20:23:36 +01:00
public function disableMFAPost ( ExistingTokenFormRequest $request ) : Redirector | RedirectResponse
2024-10-08 07:21:23 +02:00
{
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 */
2026-01-23 15:14:29 +01:00
$user = auth () -> user ();
2024-10-08 07:21:23 +02:00
2025-11-28 19:01:15 +01:00
Preferences :: delete ( 'temp-mfa-secret' );
Preferences :: delete ( 'temp-mfa-codes' );
2024-10-08 07:21:23 +02:00
$repository -> setMFACode ( $user , null );
2025-11-28 19:01:15 +01:00
Preferences :: mark ();
2024-10-08 07:21:23 +02:00
2026-01-23 15:09:50 +01:00
session () -> flash ( 'success' , ( string ) trans ( 'firefly.pref_two_factor_auth_disabled' ));
session () -> flash ( 'info' , ( string ) trans ( 'firefly.pref_two_factor_auth_remove_it' ));
2024-10-08 07:21:23 +02:00
// also logout current 2FA tokens.
$cookieName = config ( 'google2fa.cookie_name' , 'google2fa_token' );
2025-05-24 16:39:20 +02:00
Cookie :: forget ( $cookieName );
2024-10-08 07:21:23 +02:00
// send user notification.
Log :: channel ( 'audit' ) -> info ( sprintf ( 'User "%s" has disabled MFA' , $user -> email ));
2026-01-19 19:36:21 +01:00
event ( new UserHasDisabledMFA ( $user ));
2024-10-08 07:21:23 +02:00
return redirect ( route ( 'profile.index' ));
}
/**
* Enable 2 FA screen .
*/
2026-01-19 20:23:36 +01:00
public function enableMFA ( Request $request ) : Redirector | RedirectResponse | View
2024-10-08 07:21:23 +02:00
{
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 ) {
2026-01-23 15:09:50 +01:00
session () -> flash ( 'info' , ( string ) trans ( 'firefly.2fa_already_enabled' ));
2024-10-08 07:21:23 +02:00
return redirect ( route ( 'profile.index' ));
}
2026-01-23 15:14:29 +01:00
$domain = $this -> getDomain ();
$secret = Google2FA :: generateSecretKey ();
$image = Google2FA :: getQRCodeInline ( $domain , auth () -> user () -> email , $secret );
2024-10-08 07:21:23 +02:00
2025-11-28 19:01:15 +01:00
Preferences :: set ( 'temp-mfa-secret' , $secret );
2024-10-08 07:21:23 +02:00
2026-01-23 15:09:50 +01:00
return view ( 'profile.mfa.enable-mfa' , [ 'image' => $image , 'secret' => $secret ]);
2024-10-08 07:21:23 +02:00
}
/**
* Submit 2 FA for the first time .
*
2025-10-05 12:57:58 +02:00
* @ throws ContainerExceptionInterface
* @ throws NotFoundExceptionInterface
2024-10-08 07:21:23 +02:00
*/
2026-01-19 20:23:36 +01:00
public function enableMFAPost ( TokenFormRequest $request ) : Redirector | RedirectResponse
2024-10-08 07:21:23 +02:00
{
if ( ! $this -> internalAuth ) {
$request -> session () -> flash ( 'error' , trans ( 'firefly.external_user_mgt_disabled' ));
return redirect ( route ( 'profile.index' ));
}
/** @var User $user */
2026-01-23 15:14:29 +01:00
$user = auth () -> user ();
2024-10-08 07:21:23 +02:00
// verify password.
2026-01-23 15:14:29 +01:00
$password = $request -> get ( 'password' );
2026-01-23 15:09:50 +01:00
if ( ! auth () -> validate ([ 'email' => $user -> email , 'password' => $password ])) {
2024-10-08 07:21:23 +02:00
session () -> flash ( 'error' , 'Bad user pw, no MFA for you!' );
2024-10-14 05:14:52 +02:00
2024-10-08 07:21:23 +02:00
return redirect ( route ( 'profile.mfa.index' ));
}
/** @var UserRepositoryInterface $repository */
$repository = app ( UserRepositoryInterface :: class );
2025-11-28 19:01:15 +01:00
$secret = Preferences :: get ( 'temp-mfa-secret' ) ? -> data ;
2024-10-08 07:21:23 +02:00
if ( is_array ( $secret )) {
$secret = null ;
}
2026-01-23 15:14:29 +01:00
$secret = ( string ) $secret ;
2024-10-08 07:21:23 +02:00
$repository -> setMFACode ( $user , $secret );
2025-11-28 19:01:15 +01:00
Preferences :: delete ( 'temp-mfa-secret' );
2024-10-08 07:21:23 +02:00
2026-01-23 15:09:50 +01:00
session () -> flash ( 'success' , ( string ) trans ( 'firefly.saved_preferences' ));
2025-11-28 19:01:15 +01:00
Preferences :: mark ();
2024-10-08 07:21:23 +02:00
// also save the code so replay attack is prevented.
2026-01-23 15:14:29 +01:00
$mfaCode = $request -> get ( 'code' );
2024-10-08 07:21:23 +02:00
$this -> addToMFAHistory ( $mfaCode );
// make sure MFA is logged out.
if ( 'testing' !== config ( 'app.env' )) {
2025-05-24 16:39:20 +02:00
Google2FA :: logout ();
2024-10-08 07:21:23 +02:00
}
// 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 ));
2026-01-19 19:43:06 +01:00
event ( new UserHasEnabledMFA ( $user ));
2024-10-08 07:21:23 +02:00
return redirect ( route ( 'profile.mfa.backup-codes' ));
}
/**
* TODO duplicate code .
*
2025-10-05 12:57:58 +02:00
* @ throws ContainerExceptionInterface
* @ throws NotFoundExceptionInterface
2024-10-08 07:21:23 +02:00
*/
private function addToMFAHistory ( string $mfaCode ) : void
{
/** @var array $mfaHistory */
2026-01-23 15:14:29 +01:00
$mfaHistory = Preferences :: get ( 'mfa_history' , []) -> data ;
$entry = [ 'time' => Carbon :: now () -> getTimestamp (), 'code' => $mfaCode ];
2024-10-08 07:21:23 +02:00
$mfaHistory [] = $entry ;
2025-11-28 19:01:15 +01:00
Preferences :: set ( 'mfa_history' , $mfaHistory );
2024-10-08 07:21:23 +02:00
$this -> filterMFAHistory ();
}
/**
* Remove old entries from the preferences array .
*/
private function filterMFAHistory () : void
{
/** @var array $mfaHistory */
2025-11-28 19:01:15 +01:00
$mfaHistory = Preferences :: get ( 'mfa_history' , []) -> data ;
2024-10-08 07:21:23 +02:00
$newHistory = [];
2025-05-24 05:52:31 +02:00
$now = Carbon :: now () -> getTimestamp ();
2024-10-08 07:21:23 +02:00
foreach ( $mfaHistory as $entry ) {
$time = $entry [ 'time' ];
$code = $entry [ 'code' ];
2026-01-23 15:09:50 +01:00
if (( $now - $time ) <= 300 ) {
$newHistory [] = [ 'time' => $time , 'code' => $code ];
2024-10-08 07:21:23 +02:00
}
}
2025-11-28 19:01:15 +01:00
Preferences :: set ( 'mfa_history' , $newHistory );
2024-10-08 07:21:23 +02:00
}
2024-12-22 08:43:12 +01:00
2026-01-19 20:23:36 +01:00
public function index () : Factory | RedirectResponse | View
2024-12-22 08:43:12 +01:00
{
if ( ! $this -> internalAuth ) {
request () -> session () -> flash ( 'error' , trans ( 'firefly.external_user_mgt_disabled' ));
return redirect ( route ( 'profile.index' ));
}
2026-01-23 15:09:50 +01:00
$subTitle = ( string ) trans ( 'firefly.mfa_index_title' );
2024-12-22 08:43:12 +01:00
$subTitleIcon = 'fa-calculator' ;
$enabledMFA = null !== auth () -> user () -> mfa_secret ;
2026-01-23 15:09:50 +01:00
return view ( 'profile.mfa.index' ) -> with ([ 'subTitle' => $subTitle , 'subTitleIcon' => $subTitleIcon , 'enabledMFA' => $enabledMFA ]);
2024-12-22 08:43:12 +01:00
}
2024-10-08 07:21:23 +02:00
}