Laravel 8 Fortify - 2FA only when the user logs in from a new device

Viewed 1488

I am implementing two-factor authentication (2FA) in my Laravel 8 application.

The 2FA is applied every time the user logs in. However, I don't really feel that 2FA is necessary every time, I even find it annoying. As a solution I am thinking of applying it only when the user connects from a new device. Is there someone who has already done it or who can give me a hint of the changes that would be necessary?

2 Answers

I have got it. Here are the steps I have followed:

  1. In the config file fortify.php I have added

    'pipelines' => [
        'login' => [
            App\Actions\Fortify\RedirectIfTwoFactorAuthenticatable::class,
            Laravel\Fortify\Actions\AttemptToAuthenticate::class,
            Laravel\Fortify\Actions\PrepareAuthenticatedSession::class,
        ]
    ]
    
  2. I have added the field two_factor_cookies to the User class.

  3. I have customized the RedirectIfTwoFactorAuthenticatable class of Fortify:

    <?php
    
    namespace App\Actions\Fortify;
    
    use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable as DefaultRedirectIfTwoFactorAuthenticatable;
    use Laravel\Fortify\TwoFactorAuthenticatable;
    
    
    class RedirectIfTwoFactorAuthenticatable extends DefaultRedirectIfTwoFactorAuthenticatable
    {
    
        /**
         * Handle the incoming request.
         *
         * @param  \Illuminate\Http\Request  $request
         * @param  callable  $next
         * @return mixed
         */
        public function handle($request, $next)
        {
            $user = $this->validateCredentials($request);
    
            if (optional($user)->two_factor_secret &&
                in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user)) &&
                $this->checkIfUserDeviceHasNotCookie($user)) {
                return $this->twoFactorChallengeResponse($request, $user);
            }
    
            return $next($request);
        }
    
        /**
         * This checks if the user's device has the cookie stored 
         * in the database.
         *
         * @param  \App\Models\User\User  $user
         * @return bool
         */
        protected function checkIfUserDeviceHasNotCookie($user)
        {
            $two_factor_cookies = json_decode($user->two_factor_cookies);
            if (!is_array($two_factor_cookies)){
                $two_factor_cookies = [];
            }
            $two_factor_cookie = \Cookie::get('2fa');
            return !in_array($two_factor_cookie,$two_factor_cookies);
        }
    
    }
    
  4. In the FortifyServiceProvider I have added a customized TwoFactorLoginResponse.

        <?php
    
        namespace App\Providers;
    
        use App\Actions\Fortify\CreateNewUser;
        use App\Actions\Fortify\ResetUserPassword;
        use App\Actions\Fortify\UpdateUserPassword;
        use App\Actions\Fortify\UpdateUserProfileInformation;
        use App\Http\Responses\FailedPasswordResetLinkRequestResponse;
        use App\Http\Responses\FailedPasswordResetResponse;
        use App\Http\Responses\LockoutResponse;
        use App\Http\Responses\LoginResponse;
        use App\Http\Responses\LogoutResponse;
        use App\Http\Responses\PasswordResetResponse;
        use App\Http\Responses\RegisterResponse;
        use App\Http\Responses\SuccessfulPasswordResetLinkRequestResponse;
        use App\Http\Responses\TwoFactorLoginResponse;
        use App\Http\Responses\VerifyEmail;
        use Illuminate\Cache\RateLimiting\Limit;
        use Illuminate\Http\Request;
        use Illuminate\Support\Facades\RateLimiter;
        use Illuminate\Support\ServiceProvider;
        use Laravel\Fortify\Contracts\FailedPasswordResetLinkRequestResponse as FailedPasswordResetLinkRequestResponseContract;
        use Laravel\Fortify\Contracts\FailedPasswordResetResponse as FailedPasswordResetResponseContract;
        use Laravel\Fortify\Contracts\LockoutResponse as LockoutResponseContract;
        use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
        use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
        use Laravel\Fortify\Contracts\PasswordResetResponse as PasswordResetResponseContract;
        use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
        use Laravel\Fortify\Contracts\SuccessfulPasswordResetLinkRequestResponse as SuccessfulPasswordResetLinkRequestResponseContract;
        use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
        use Laravel\Fortify\Fortify;
    
        class FortifyServiceProvider extends ServiceProvider
        {
            /**
             * Register any application services.
             *
             * @return void
             */
            public function register()
            {
                $this->registerResponseBindings();
            }
    
            /**
             * Register the response bindings.
             *
             * @return void
             */
            protected function registerResponseBindings()
            {
                $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
                $this->app->singleton(LogoutResponseContract::class, LogoutResponse::class);
                $this->app->singleton(TwoFactorLoginResponseContract::class, TwoFactorLoginResponse::class);
                $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
    
                $this->app->singleton(LockoutResponseContract::class, LockoutResponse::class);
                $this->app->singleton(SuccessfulPasswordResetLinkRequestResponseContract::class, SuccessfulPasswordResetLinkRequestResponse::class);
                $this->app->singleton(FailedPasswordResetLinkRequestResponseContract::class, FailedPasswordResetLinkRequestResponse::class);
                $this->app->singleton(PasswordResetResponseContract::class, PasswordResetResponse::class);
                $this->app->singleton(FailedPasswordResetResponseContract::class, FailedPasswordResetResponse::class);        
            }
    
            /**
             * Bootstrap any application services.
             *
             * @return void
             */
            public function boot()
            {
    
                Fortify::ignoreRoutes();
                Fortify::loginView(function () {
                    return view('auth.login');
                });
                Fortify::twoFactorChallengeView('auth.two-factor-challenge');
                Fortify::confirmPasswordView(function (Request $request) {
                    if ($request->ajax()) {
                        return view('auth.confirm-password-form');
                    } else {
                        return view('auth.confirm-password');
                    }
                });
    
                Fortify::requestPasswordResetLinkView(function () {
                    return view('auth.forgot-password');
                });
                Fortify::resetPasswordView(function ($request) {
                    return view('auth.reset-password', ['request' => $request,'token' => $request->route('token')]);
                });
                Fortify::registerView(function () {
                    return view('auth.register'); 
                });
                Fortify::verifyEmailView(function () {
                    return view('auth.verify');
                });
    
                Fortify::createUsersUsing(CreateNewUser::class);
                Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
                Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
                Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
    
                /*RateLimiter::for('login', function (Request $request) {
                    return Limit::perMinute(5)->by($request->email.$request->ip());
                });*/
    
                RateLimiter::for('two-factor', function (Request $request) {
                    return Limit::perMinute(5)->by($request->session()->get('login.id'));
                });
            }
        }
    
  5. Finally, the TwoFactorLoginResponse:

    <?php
    
    namespace App\Http\Responses;
    
    use Illuminate\Http\JsonResponse;
    use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
    
    class TwoFactorLoginResponse implements TwoFactorLoginResponseContract
    {
        /**
         * Create an HTTP response that represents the object.
         *
         * @param  \Illuminate\Http\Request  $request
         * @return \Symfony\Component\HttpFoundation\Response
         */
        public function toResponse($request)
        {
            $user = \Auth::user();
    
            $this->storeCookieIfNotInDB($user);
    
            $role = $user->role;
    
            if ($request->wantsJson()) {
                return new JsonResponse('', 204);
            }
    
            if ($role == "0") {
                return redirect()->route('user.home');
            } else {
                return redirect()->route('admin.home');
            }
        }
    
        /**
         * Store the cookie if it is not in the database.
         *
         * @param  \App\Models\User\User  $user
         * @return void
         */
        protected function storeCookieIfNotInDB($user)
        {
            $two_factor_cookies = json_decode($user->two_factor_cookies);
            if (!is_array($two_factor_cookies)){
                $two_factor_cookies = [];
            }
            $two_factor_cookie = \Cookie::get('2fa');
    
            if (!in_array($two_factor_cookie,$two_factor_cookies)) {
                $two_factor_cookie = md5(now());
                $two_factor_cookies[] = $two_factor_cookie;
                if (count($two_factor_cookies) > 3) {
                    array_shift($two_factor_cookies);
                }
    
                $user->two_factor_cookies = json_encode($two_factor_cookies);
                $user->save();
    
                $lifetime = 60 * 24 * 365; //one year
                \Cookie::queue('2fa',$two_factor_cookie,$lifetime);
            }
        }
    }
    

Upon login, it will look for the cookie 2fa. If its content is stored in the database, it will not be necessary to enter the code again. To prevent unlimited cookie content from being saved in the DB you can add a maximum limit (I have set it 3).

Thanks to Maarten Veerman for the inital help.

According to this line: https://github.com/laravel/fortify/blob/82c99b6999f7e89f402cfd7eb4074e619382b3b7/src/Http/Controllers/AuthenticatedSessionController.php#L80

you can create a pipelines.login entry in your fortify config file.

The solution would be to:

  • create the config entry
  • copy the pipeline setup in the above file, line 84.
  • create a custom AttemptToAuthenticate class, make sure the pipeline config points to your new class.
  • make the new class extend the default fortify AttemptToAuthenticate class.
  • overwrite the handle function, add your logic in the new function, where you check for a cookie on the device.
Related