Abnormal's avatar

Seeking Best Practice Advice on 2 factor authentication with Socialite.

Hello all, I've got a project where I'm using Laravel Fortify with 2 factor authentication. I've got the enable and disable functionality along with the confirmation working. I'm also using Laravel Socialite to make it easier for users to login/logout. I've got this working separately as well. The problem is that I have some legal requirements that require 2 factor authentication. So I've been trying to get 2 factor authentication to work with Socialite.

What are some best practices around implementing 2 factor authentication with oauth? I know that some oauth providers give their own version of 2 factor authentication but I believe that depends on the user's settings right?

For now I've implemented a middleware

class CustomTwoFactorMiddleware
{
    /**
     * The guard implementation.
     *
     * @var \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected $guard;

    /**
     * The login rate limiter instance.
     *
     * @var \Laravel\Fortify\LoginRateLimiter
     */
    protected $limiter;

    /**
     * Create a new controller instance.
     *
     * @param  \Illuminate\Contracts\Auth\StatefulGuard  $guard
     * @param  \Laravel\Fortify\LoginRateLimiter  $limiter
     * @return void
     */
    public function __construct(StatefulGuard $guard, LoginRateLimiter $limiter)
    {
        Log::info("Creating middleware");
        $this->guard = $guard;
        $this->limiter = $limiter;
    }

    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  callable  $next
     * @return mixed
     */
    public function handle($request, $next)
    {
        Log::info("handling validation");
        // $user = $this->validateCredentials($request);
        //Assume that user has already authenticated and we are checking for two factor validation only.
        $user = $request->user();

        if (Fortify::confirmsTwoFactorAuthentication()) {
            if (optional($user)->two_factor_secret &&
                ! is_null(optional($user)->two_factor_confirmed_at) &&
                in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user)) && 
                $request->hasChallengedUser() == false
                ) { 
                    Log::info("Checking for two factor");
                return $this->twoFactorChallengeResponse($request, $user);
            } else {
                Log::info("two factor skipped");
                return $next($request);
            }
        }

        if (optional($user)->two_factor_secret &&
            in_array(TwoFactorAuthenticatable::class, class_uses_recursive($user))  && 
            $request->hasChallengedUser() == false
            ) {
                Log::info("two factor skipped");
            return $this->twoFactorChallengeResponse($request, $user);
        }
        Log::info("two factor skipped");
        return $next($request);
    }

    /**
     * Attempt to validate the incoming credentials.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return mixed
     */
    protected function validateCredentials($request)
    {
        if (Fortify::$authenticateUsingCallback) {
            Log::info(print_r(Fortify::$authenticateUsingCallback,true));
            return tap(call_user_func(Fortify::$authenticateUsingCallback, $request), function ($user) use ($request) {
                if (! $user) {
                    $this->fireFailedEvent($request);

                    $this->throwFailedAuthenticationException($request);
                }
            });
        }

        $model = $this->guard->getProvider()->getModel();

        return tap($model::where(Fortify::username(), $request->{Fortify::username()})->first(), function ($user) use ($request) {
            if (! $user || ! $this->guard->getProvider()->validateCredentials($user, ['password' => $request->password])) {
                $this->fireFailedEvent($request, $user);

                $this->throwFailedAuthenticationException($request);
            }
        });
    }

    /**
     * Throw a failed authentication validation exception.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function throwFailedAuthenticationException($request)
    {
        $this->limiter->increment($request);

        throw ValidationException::withMessages([
            Fortify::username() => [trans('auth.failed')],
        ]);
    }

    /**
     * Fire the failed authentication attempt event with the given arguments.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Illuminate\Contracts\Auth\Authenticatable|null  $user
     * @return void
     */
    protected function fireFailedEvent($request, $user = null)
    {
        event(new Failed(config('fortify.guard'), $user, [
            Fortify::username() => $request->{Fortify::username()},
            'password' => $request->password,
        ]));
    }

    /**
     * Get the two factor authentication enabled response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  mixed  $user
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function twoFactorChallengeResponse($request, $user)
    {
        Log::info("Attempting to route to two-factor auth");
        $request->session()->put([
            'login.id' => $user->getKey(),
            'login.remember' => $request->boolean('remember'),
            'doing_two_factor' => true
        ]);

        TwoFactorAuthenticationChallenged::dispatch($user);
        
        return $request->wantsJson()
                    ? response()->json(['two_factor' => true])
                    : redirect()->route('two-factor.login');
    }
}

This works and I am able to enable two factor authentication.

But I have run into an issue on the two-factor challenge where the application doesn't have a ChallengeUser because authentication isn't from Fortify but Socialite resulting in application perpetually redirecting to the two-factor challenge.. Is this the right approach or is there a better way to implement this?

Thank you for any assistance you can provide.

0 likes
2 replies
martinbean's avatar

@abnormal1147 Socialite is just a package that enables you to retrieve OAuth tokens from OAuth providers (such as Google). What you do afterwards with that token, is up to you.

So, if you want to enforce 2FA, then once you’ve got an OAuth token and looked a user up, you would put them through the 2FA flow rather than immediately authenticating them.

Abnormal's avatar

@martinbean So I put together a flow to implement this conceptually and to gain a better understanding of how fortify and socialite work. I've added areas in purple where I could think of adding fortify's 2 factor auth to socialite. I'm not sure if this is a correct implementation but from what I could tell, the RedirectifTwoFactorAuthenticable middleware that is part of the login pipeline needs to be replaced to handle users without passwords in the Fortify 2FA flow. So I figured this is where you could place your custom middleware that handles validateCredentials slightly differently for OAuth users. I'm providing a link to the flow here incase other users have a similar issue and for easier visualization of the current Fortify implementation.

If you can't see the image, the link is imgur.com/a/zBj6cnH, just add the protocol prefix cause I can't post links yet.

Is this a good approach or would you implement it differently?

Please or to participate in this conversation.