Preventing User Enumeration Attack in Laravel Apps
Tags: security, laravel, tutorial, php
Published: 2022-05-13 13:42:30

User enumeration is a brute-force technique that is used to collect or verify valid users' credentials. It belongs to the **Identification and Authentication Failures** category (AKA broken authentication) which came in 7th place on the [Top 10 Web Application Security Risks 2021](https://owasp.org/www-project-top-ten/) by OWASP down from the 2nd place in the 2017 version. It can also be part of phishing attack or other brute-force attacks like [credential stuffing](https://owasp.org/www-community/attacks/Credential_stuffing) and [password spraying](https://owasp.org/www-community/attacks/Password_Spraying_Attack).
Bear in mind that user enumeration vulnerabilities are not exclusive for web applications, it can be found in any system that requires authentication.

#### Attacking Scenarios

The malicious actor will be basically looking for differences in the authentication server's response, in terms of response time and body, based on the authenticity of submitted credentials.  This can happen while using one of the following functionalities:

- Login
- Registration
- Password Reset

#### Laravel Authentication

Laravel provide us with robust solutions and starter kits for authentication so let's start by creating a new Laravel 9 project and install [Jetstram](https://jetstream.laravel.com)

```shell
laravel new user-enum

composer require laravel/jetstream
```

and for simplicity we will use SQLite `3.36.0`

```env
DB_CONNECTION=sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE="[PROJECT_PATH]/user-enum/database/database.sqlite"
DB_USERNAME=root
DB_PASSWORD=
```

#### Login

Out of the box, the error message is consistent whether you entered an incorrect email or password.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nf833yg50133rtbt90uu.png)

#### Password Reset

Here, the error message discloses that a user doesn't exist in the system so, as we mentioned, we need to make the response message and time consistent.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cgw5cs010zrkzeuqk8b2.png)

Moreover, a malicious user can validate user email address existence by sending the reset password request twice. If the email address is valid,  on the second attempt a different error message is returned


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ufxl9ukh6es8jaso1svx.png)

Under the hood, Jetstream is using [Fortify](https://laravel.com/docs/9.x/fortify) which is a frontend-agnostic authentication implementation. Our first stop will be the controller registered by Fortify for password reset.

```shell
php artisan route:list
  ...
  POST  forgot-password ............. password.email › Laravel\Fortify › PasswordResetLinkController@store
```

Let's see what the method `Laravel\Fortify\PasswordResetLinkController::store` do:

1. First, it validates the submitted email address.
2. Then the password broker tries to send the reset email to the user of the submitted email address.
3. Based on the return value of the `\Illuminate\Contracts\Auth\PasswordBroker::sendResetLink()` method, the response is determined hence the user information is disclosed.

```php
public function store(Request $request): Responsable
{
    $request->validate([Fortify::email() => 'required|email']);

    // We will send the password reset link to this user. Once we have attempted
    // to send the link, we will examine the response then see the message we
    // need to show to the user. Finally, we'll send out a proper response.
    $status = $this->broker()->sendResetLink(
        $request->only(Fortify::email())
    );

    return $status == Password::RESET_LINK_SENT
                ? app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => $status])
                : app(FailedPasswordResetLinkRequestResponse::class, ['status' => $status]);
}
```

To fix this, we will create our controller at `App\Http\Controller\Auth\PasswordResetLinkController` that will extend Fortify's controller. The difference here is that instead of returning the invalid email address error, we will log it and return the same `SuccessfulPasswordResetLinkRequestResponse` message.

```php
public function store(Request $request): Responsable
{
    $request->validate([Fortify::email() => 'required|email']);
    $email = $request->only(Fortify::email());
    $status = $this->broker()->sendResetLink($email);

    if ($status !== Password::RESET_LINK_SENT) {
        Log::error(__($status, [], 'en'), ['email' => $email]);
    }

    return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
}
```

Then we need to define our `reset-password` route in `routes/web.php` that will point to the above controller

```php
Route::post('reset-password', 'App\Http\Controllers\Auth\PasswordResetLinkController@store')
    ->name('password.email')
    ->middleware('guest');
```

At this point, the response message will be the same regardless of the email validity. But, what about the response time? as you can see in the below screenshot, the first request, with an invalid email, took less than a second, while the one with a valid email took over 6 seconds!

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fddifvvmm77rj4pfvfrb.png)

One way to reduce the gap between the two scenarios is to make the `Illuminate\Auth\Notifications\ResetPassword` notification queueable. So, again we will create our version of this notification at `App\Notifications\ResetPassword` and make it so. Check the Laravel docs for more information on [queuing notifications](https://laravel.com/docs/9.x/notifications#queueing-notifications)

```php
<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\ResetPassword as CoreResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;

class ResetPassword extends CoreResetPassword implements ShouldQueue
{
    use Queueable;
}
```

Then we will override the `Illuminate\Auth\Passwords\CanResetPassword::sendPasswordResetNotification()` method in the `App\Models\User` model

```php
public function sendPasswordResetNotification($token)
{
    $this->notify(new ResetPassword($token));
}
```

#### Registration

I think you already know the answer to this. Yes, the registration response will disclose whether the user email exist or not.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rysjeegbh1iysc552l3d.png)

To make the response consistent, we will [enable user email verification](https://laravel.com/docs/9.x/verification#model-preparation) so that an email is sent in both scenarios

Then publish Fortify resources so that its actions become editable. Specifically `App\Actions\Fortify\CreateNewUser`

```bash
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
```

The changes is quite simple, first, we remove the `unique` rule from the `email` input. Then, if a user with the submitted email address exist, an email will be sent to notify him/her. 

```php
public function create(array $input): ?User
{
    Validator::make($input, [
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255'],
        'password' => $this->passwordRules(),
        'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
    ])->validate();

    if ($user = User::whereEmail($input['email'])->first()) {
        $user->notify(new AlreadyHaveAccount());
        return ;
    }

    return User::create([
        'name' => $input['name'],
        'email' => $input['email'],
        'password' => Hash::make($input['password']),
    ]);
}
```

And the `App\Notifications\AlreadyHaveAccount` notification will be

```php
<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Lang;

class AlreadyHaveAccount extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct()
    {
    }

    public function via(mixed $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(mixed $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('New Registration Attempt with Your Email Address')
            ->line('You are receiving this email because we received a registration request with your registered email address.')
            ->action('Login Instead', route('login'))
            ->line(Lang::get('If you did not try to register a new account, no further action is required.'));
    }
}
```

Next, we will create a job that accept `CreateNewUser` action and the submitted input to make the response time consistent

```php
<?php

namespace App\Jobs;

use Illuminate\Auth\Events\Registered;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Laravel\Fortify\Contracts\CreatesNewUsers;

class RegisterUser implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(private CreatesNewUsers $creator, private array $input)
    {
        //
    }

    public function handle()
    {
        $user = $this->creator->create($this->input);

        if ($user) {
            event(new Registered($user));
        }
    }
}
```

And finally, create a controller for registration, like we did with resetting passwords, and make the `register` route point to it

```php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Responses\RegisterResponse;
use App\Jobs\RegisterUser;
use Illuminate\Http\Request;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Fortify\Http\Controllers\RegisteredUserController as FortifyRegisteredUserController;

class RegisteredUserController extends FortifyRegisteredUserController
{
    public function store(Request $request, CreatesNewUsers $creator): RegisterResponse
    {

        RegisterUser::dispatch($creator, $request->all());

        return app(RegisterResponse::class);
    }
}
```

```php
Route::post('register', 'App\Http\Controllers\Auth\RegisteredUserController@store')
    ->middleware('guest');
```

Now login, reset password and registration will never disclose existing users information and our app is protected against user enumeration attacks ✌.