laravel / fortify

Backend controllers and scaffolding for Laravel authentication.

Home Page:https://laravel.com/docs/fortify

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Provide and Document a secure way to implement case-insensitive "login" and "forgot password" methods

dfidler opened this issue · comments

Problem Statement:

The default behavior of all of fortify methods is to do a case-sensitive search for the User model on the configured property (most ofen some variation of "email").

This is a problem that the vast majority of users MUST solve (case insensistive username search), but there is nothing in Laravel's/Fortify's documentation on how to solve it (and I'm still struggling to find a decent solution for it).

Googling "laravel fortify case sensitive login" will inevitably lead you to some Q&A sites that suggest various fixes; some of them okay and some of them add vulnerabilities to your authentication system. Ouch.

More Detail

So, in mysql/mariadb/mssql, the prevailing solution seems to be to change the collation of your "username" column. This allows case-insensitive searches on it.

In SQLite, varchars are case-insensitive by default (but I can't imagine anyone on the planet using sqlite in production).

That solution doesn't work for postgres. So digging around you'll find lots of suggestions like:

  1. Add a custom Fortify::athenticateUsing(callable $callback) to your FortifyServiceProvider using "ilike"; something like this:
        // inside app/Providers/FortifyServiceProvider.php::boot()
        Fortify::authenticateUsing(function (Request $request) {
            $user = User::where('email', 'ilike', strtolower($request->email))->first();
 
            if ($user &&
                Hash::check($request->password, $user->password)) {
                return $user;
            }
        });

This suggestion comes up in almost every thread on the subject, which is scary as hell because it creates a vulnerability in the site's authentication pipeline. An attacker can do a brute force/dictionary password attack on "%@gmail.com" (and many other hosted domains). And I've never seen anyone mention this in the threads. Ouch.

PS - for anyone that's found this thread through google, the better version is:

        Fortify::authenticateUsing(function (Request $request) {
            $user = User::whereRaw('lower(email) = ?', [strtolower($request->email)])->first();
            ...

(Actually, a better version would use firstOrFail() and then have an exception handler that also manages unexpected exceptions, but hey-ho, whatever.)

The next thing you'll then find out is that the above doesn't solve the issue of password resets (forgot password), which still does a case-sensitive search for the User object before passing it tot he singleton.

The next, most logical thought to me, was "send a callable to Fortify::resetPasswordUsing( )" but that takes a string to a singleton class (with a contract for a reset($user, $input) method) - and from what I can tell, there's no way to override the logic for finding the User record that matches the request input (which, on further thought, seems like the most elegant solution to me - users can change the code in one place, and that overridden method is re-used by all other actions in the pipeline - something like a Fortify::getAuthenticatable($request): Model - but then there are many others with more indepth knowledge of the code base, than I so maybe not).

Other workarounds to the problem suggest adding setter/getter methods to the User model for the attribute that do a strtolower() of the property (so all values are stored in lower, and all values are fetched in lower). But as far as I can tell this requires:

  • destructive operations on the data being entered by users - the purist in me balks at this suggestion (and many others say they don't want to modify the case of their stored data - I'm not alone here)
  • though I haven't tried this yet, I'd imagine that you'd need to modify your js front-end (I'm not using views) to lowercase the $request->email value before sending them to the forgot password controller.

But this seems like a lot of workaround to what, IMHO, should be almost a binary option in the system that you can enable/disable with one small change of code (or configuration).

Summary

Modern sites use emails as usernames so that means that this is a problem that every user is going to encounter. For those that use postgres (which is the best open source production database out there, full-stop :) ), this journey seems to be fraught with pitfalls and special cases.

My desired outcomes from this issue are:

  1. The project decides on a consistent, recommended, solution for this use case (ideally a platform agnostic one)
  2. The recommended solution (if one already exists) is documented in the Fortify/Laravel docs because I think this use case is pervasive enough to warrant special consideration

This way, users are getting advice on a standard, secure solution, instead of getting really bad advice (and vulnerabilities) from Q&A websites.

Hi @dfidler. Thanks for your detailed issue. Right now, we're not experiencing any problems ourselves with this. It seems there are some solutions you provided. You can always attempt a PR to the docs or maybe even write a detailed blog post that people can refer to? Right now, I don't think this issue is serious enough for us to take action. Sorry.

@driesvints is that because you don't use postgres or because you've found another solution to it?

@driesvints this issue really is a pain to fix. Even when using a custom user provider, the RedirectIfTwoFactorAuthenticatable class will inexplicably do its own user retrieval thing:

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);
    }
});

It's even using the user provider in the tap callback. Why wouldn't you just retrieve the user from that same provider, right there, using the retrieveByCredentials method?

There are no good solutions, only bad ones. This bites every single Fortify application using Postgres. How is that not serious enough??

Thanks @Radiergummi. What do you think would be the best solution/action to undertake here?

The best would be to add an optional pipeline step that would lowercase the username, if desired; additionally, Fortify shouldn't have an indirect dependency on the Eloquent user provider. Instead of calling $model::where(), it should defer account retrieval to the UserProvider::retrieveByCredentials method.
I'd actually be happy to contribute a PR for this, but I'm pretty sure it would be declined as irrelevant, so I'm hesitant to put in the required working hours.

Actually, coming to think of it, this is something an authentication framework should handle on itself if it detects a Postgres database (in MySQL, it's just undefined behaviour). Ideally, there would be a configuration option for Fortify that is enabled by default.

I think we could maybe look into the former. However:

additionally, Fortify shouldn't have an indirect dependency on the Eloquent user provider.

Fortify is currently only intended to be used with Eloquent. Right now we're not considering other providers, sorry.

If you or someone else could kickstart a PR for the casing issue we can work from there. Definitely make sure the issue is clearly explained in the PR description for Taylor to understand. Thanks!

Hey @driesvints, I opened a PR which adds a new configuration option to prevent this issue: #485. If you have a sec, would really appreciate your thoughts on the description clarity :)

@Radiergummi Thanks for weighing in, sorry I haven't responded until now (disconnected during holidays).

My primary goal with this issue was - because there are so many ways to solve this problem (in so many threads, most of which are bad) - to have a discussion on how best to make this a "work" for newcomers to Laravel. "case-insensitive" username validation has been ubiquitous pretty much since the 90s and having that as a gap in Laravel seems really odd to me.

Looking at your PR, it looks like you are storing usernames in lowercase (if the option is set) which is what some of the respondents in threads didn't want to do (though it is a valid solution).

FWIW, a member of our team solved this with a custom AuthServiceProvider that extended EloquentUserProvider to override retreiveByCredentials() (see below). This ticks the boxes for security (no use of ILIKE, not vulnerable to SQL injection) and it means that we don't mess with the case of the emails being entered in by our users. But it doesn't look like it will work, as you say, for the 2FA use case that you mention above (which is good to know because while we're not doing 2FA yet, it is on the roadmap).

Obviously this is hard-coded to "email" (for us) but it could be made to be more general. Unfortunately, I still don't know the code base well enough to know if there is a more elegant solution.

App\Providers\AuthServiceProvider

<?php

namespace App\Providers;

use Auth;
use Closure;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
     // .... snip ....

    public function boot()
    {
        // .... snip ....

        Auth::provider('eloquent', function($app, array $config) {
            return new class ($app['hash'], $config['model']) extends EloquentUserProvider {

                public function retrieveByCredentials(array $credentials)
                {
                    $credentials = array_filter(
                        $credentials,
                        fn ($key) => ! str_contains($key, 'password'),
                        ARRAY_FILTER_USE_KEY
                    );

                    if (empty($credentials)) {
                        return;
                    }

                    $query = $this->newModelQuery();

                    foreach ($credentials as $key => $value) {
                        if (is_array($value) || $value instanceof Arrayable) {
                            $query->whereIn($key, $value);
                        } elseif ($value instanceof Closure) {
                            $value($query);
                        } elseif ($key == 'email') {
                            $query->whereRaw('lower(email) = ?', [strtolower($value)]);
                        } else {
                            $query->where($key, $value);
                        }
                    }
                    return $query->first();
                }
            };
        });
    }
}

@dfidler:

Looking at your PR, it looks like you are storing usernames in lowercase (if the option is set) which is what some of the respondents in threads didn't want to do (though it is a valid solution).

Yeah, I can understand that from a purity perspective, but there's no actual benefit to storing usernames like the user provided them - or at least I haven't heard any yet. If you keep un-canonicalised versions in your database, there are all kinds of implicit ambiguities when answering questions like "do we have a user with that email" or "is that name taken yet". Forget it once, run into errors somewhere. All that just to show users an uppercase letter in their email address?

FWIW, a member of our team solved this with a custom AuthServiceProvider that extended EloquentUserProvider to override retreiveByCredentials() (see below).

Yup, that's what I did too, because I forced a custom user provider onto Fortify anyway. I wanted to keep most of the original provider, however:

use Illuminate\Contracts\Auth\UserProvider as BaseUserProvider;

class UserProvider implements BaseUserProvider
{
    public function __construct(
        // inject the original Eloquent provider in AuthServiceProvider,
        // so we can defer calls to it
        private readonly BaseUserProvider $provider,
    ) {
    }

    // ...

    public function retrieveByCredentials(array $credentials): Authenticatable|null
    {
        return $this->provider->retrieveByCredentials(
            array_map(
                fn (string $value) => [$value, mb_strtolower($value)],
                $credentials,
            )
        );
    }
}

This will use the parent's retrieveByCredentials implementation, but try both the original username and a lowercase version as a WHERE IN (doing both helps the transition).

And to make 2FA work, I created a child class of the RedirectIfTwoFactorAuthenticatable action which overrides the validateCredentials method:

use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable as Action;

class RedirectIfTwoFactorAuthenticatable extends Action
    protected function validateCredentials($request): mixed
    {
        if (Fortify::$authenticateUsingCallback) {
            $user = call_user_func(Fortify::$authenticateUsingCallback, $request);

            if (!$user) {
                $this->fireFailedEvent($request);
                $this->throwFailedAuthenticationException($request);
            }

            return $user;
        }

        $provider = $this->guard->getProvider();
        assert($provider instanceof UserProvider);
        $user = $provider->retrieveByCredentials([
            User::EMAIL => $request->input(User::EMAIL),
        ]);

        if (!$user || !$provider->validateCredentials($user, [
                User::PASSWORD => $request->input(User::PASSWORD)
            ])) {
            $this->fireFailedEvent($request, $user);
            $this->throwFailedAuthenticationException($request);
        }

        return $user;
    }
}

The implementation does the same things as its parent's does, basically, but relies on the user provider's retrieveByCredentials instead of the implicit Eloquent query to fetch the user instance (and I removed those useless tap calls which just make everything harder to understand).
If you do it like this, you should be able to make 2FA work on your end!

This is still a bad solution, however. I'll be doomed to compare implementations on every future update.


Let's hope Taylor agrees with the PR, though. That would alleviate all of this :)