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 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 and password spraying. 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
laravel new user-enum
composer require laravel/jetstream
and for simplicity we will use SQLite 3.36.0
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.
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.
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
Under the hood, Jetstream is using Fortify which is a frontend-agnostic authentication implementation. Our first stop will be the controller registered by Fortify for password reset.
php artisan route:list
...
POST forgot-password ............. password.email › Laravel\Fortify › PasswordResetLinkController@store
Let's see what the method Laravel\Fortify\PasswordResetLinkController::store
do:
- First, it validates the submitted email address.
- Then the password broker tries to send the reset email to the user of the submitted email address.
- Based on the return value of the
\Illuminate\Contracts\Auth\PasswordBroker::sendResetLink()
method, the response is determined hence the user information is disclosed.
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.
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
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!
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
<?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
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.
To make the response consistent, we will enable user email verification so that an email is sent in both scenarios
Then publish Fortify resources so that its actions become editable. Specifically App\Actions\Fortify\CreateNewUser
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.
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
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
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
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);
}
}
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 ✌.