laravel / framework

The Laravel Framework.
https://laravel.com
MIT License
32.38k stars 10.98k forks source link

Password reset e-mail missing e-mail in URL #15733

Closed rafaelrenanpacheco closed 8 years ago

rafaelrenanpacheco commented 8 years ago

When sending the password reset e-mail in Laravel 5.3, the reset link have a token, but doesn't have the user e-mail. This way, the reset form will not load the user e-mail. In 5.2, the reset link had the user e-mail.

Without the user e-mail in the URL, Illuminate\Foundation\Auth\ResetsPasswords.php will send a null e-mail in showResetForm, because $request->email will evaluate to null. Then, the reset form provided by the framework from Illuminate\Auth\Console\stubs\make\views\auth\passwords\email.stub will show a blank e-mail in the following input:

<input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required>

Digging up who missed sending the e-mail in the URL, we can found a Illuminate\Auth\Notifications\ResetPassword.php which does the following action:

->action('Reset Password', url('password/reset', $this->token))

As you can see, by default Laravel does not add the e-mail in the request URL. Since this trait usually is overwritten, we can add the e-mail to the URL to make things working again. The issue here is that, by default, Laravel is missing this e-mail in the request url.

Steps To Reproduce:

Send a reset e-mail link and open the link provided in the e-mail.

JohnnyWalkerDigital commented 6 years ago

In terms of UX it seems quite annoying to ask the user to type their password in again, but it's undoubtedly less secure. I guess it depends on how important security is in your app.

Side note: I've updated my answer above. I had previously missed out some minor steps (which could be deduced, but I've explicitly mentioned them now), and I've successfully tested it with a fresh installation of Laravel 5.6.

pedrofurtado commented 6 years ago

@khalilst @Mayonado @zmonteca

I have fixed it by editing/hacking ResetPassword.php notification & reset.blade.php file. Now this does mean you are editing vendor file, so it's your choice to do this or not.

ResetPassword.php

The file can be found at (5.4):

../vendor/laravel/framework/src/Illuminate/Auth/Notifications/ResetPassword.php

Amend the toMail method:

From:

public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('You are receiving this email because we received a password reset request for your account.')
            ->action('Reset Password', url(config('app.url').route('password.reset', $this->token, false)))
            ->line('If you did not request a password reset, no further action is required.');
    }

To

public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('You are receiving this email because we received a password reset request for your account.')
            ->action('Reset Password', url(config('app.url') . route('password.reset', [$this->token, 'email=' . $notifiable->email], false)))
            ->line('If you did not request a password reset, no further action is required.');
    }

Notice the updated parameters argument, $notifiable resolves to User object hence you can access the email property:

[$this->token, 'email=' . $notifiable->email]

reset.blade.php

The file can be found at (5.4):

../resources/views/auth/passwords/reset.blade.php

The HTML may slightly differ here based on your CSS framework. In addition, you may want to show email field as read-only or make it hidden input. I prefer the user seeing the email they requested the password reset for.

From

<input type="text" id="email" name="email" class="input {{ $errors->has('email') ? ' is-danger' : '' }}" value="{{ old('email') }}">

To

<input type="text" id="email" name="email" class="input {{ $errors->has('email') ? ' is-danger' : '' }}" value="{{ $email }}" readonly>

Notice the updated value attribute, added the readonly attribute to stop user amending the email.

... value="{{ $email }}" readonly>

Hope this helps others.

Now, you don't need to override the vendor file of Notification. One way to override the toMail method is creating a service provider like this:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Support\Facades\Lang;
use Illuminate\Notifications\Messages\MailMessage;

class NotificationServiceProvider extends ServiceProvider
{
    public function boot()
    {
        ResetPasswordNotification::toMailUsing(function ($notifiable, $token) {
            return (new MailMessage)
                   ->subject(Lang::getFromJson('Reset Password Notification'))
                   ->line(Lang::getFromJson('You are receiving this email because we received a password reset request for your account.'))
                   ->action(Lang::getFromJson('Reset Password'), url(config('app.url').route('web.password.reset', [$token, 'email=' . $notifiable->email], false)))
                   ->line(Lang::getFromJson('If you did not request a password reset, no further action is required.'));
        });
    }
}

Then, registry your service provider in config/app.php:

<?php

return [
  # ...
  'providers' => [
    # ...
    App\Providers\NotificationServiceProvider::class
  ]
];
Jameron commented 6 years ago

These work arounds are great, but it is a huge waste of everyone's cumulative time to have to implement a workaround for a framework.

Check out what the reset password form looks like for Hulu.com, notice they do not require you to type in your email address. I looked at the logic for Laravels PW reset and it seems we cannot omit the email address from the form. If you could check the hash in the DB it would work, but MySQL does not support bcrypt. Does this mean that hulu us hashing their tokens using a MySQL supported hash algorythm such as SHA?

Is there an issue with storing the plain text token in the DB?

screen shot 2018-10-23 at 9 27 37 pm

JohnnyWalkerDigital commented 5 years ago

The solution I posted here solves this problem (at least from a UX perspective) by hashing the user’s email and hiding it in the URL.

jhoanborges commented 5 years ago

@khalilst @Mayonado @zmonteca

I have fixed it by editing/hacking ResetPassword.php notification & reset.blade.php file. Now this does mean you are editing vendor file, so it's your choice to do this or not.

ResetPassword.php

The file can be found at (5.4):

../vendor/laravel/framework/src/Illuminate/Auth/Notifications/ResetPassword.php

Amend the toMail method:

From:

public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('You are receiving this email because we received a password reset request for your account.')
            ->action('Reset Password', url(config('app.url').route('password.reset', $this->token, false)))
            ->line('If you did not request a password reset, no further action is required.');
    }

To

public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('You are receiving this email because we received a password reset request for your account.')
            ->action('Reset Password', url(config('app.url') . route('password.reset', [$this->token, 'email=' . $notifiable->email], false)))
            ->line('If you did not request a password reset, no further action is required.');
    }

Notice the updated parameters argument, $notifiable resolves to User object hence you can access the email property:

[$this->token, 'email=' . $notifiable->email]

reset.blade.php

The file can be found at (5.4):

../resources/views/auth/passwords/reset.blade.php

The HTML may slightly differ here based on your CSS framework. In addition, you may want to show email field as read-only or make it hidden input. I prefer the user seeing the email they requested the password reset for.

From

<input type="text" id="email" name="email" class="input {{ $errors->has('email') ? ' is-danger' : '' }}" value="{{ old('email') }}">

To

<input type="text" id="email" name="email" class="input {{ $errors->has('email') ? ' is-danger' : '' }}" value="{{ $email }}" readonly>

Notice the updated value attribute, added the readonly attribute to stop user amending the email.

... value="{{ $email }}" readonly>

Hope this helps others.

You're a genius. I was still facing this issue with laravel 5.6

arshidkv12 commented 5 years ago

Just add the following code in Model User.php

 /**
 * Send the password reset notification.
 *
 * @param  string  $token
 * @return void
 */
public function sendPasswordResetNotification($token)
{
    $this->notify(new ResetPasswordNotification($token.'/'.$this->email));
}
ali-awwad commented 5 years ago

I faced this issue with laravel 5.7

The fix was very simple, update file resources\views\auth\passwords\reset.blade.php:

change the line from: <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email or old('email') }}" required autofocus>

to

<input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email ?? old('email') }}" required autofocus>

notice the difference is the little 'or' to '??'

amarjit-singh commented 5 years ago

I faced this issue with laravel 5.7

The fix was very simple, update file resources\views\auth\passwords\reset.blade.php:

change the line from: <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email or old('email') }}" required autofocus>

to

<input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email ?? old('email') }}" required autofocus>

notice the difference is the little 'or' to '??'

It's not working

JohnnyWalkerDigital commented 5 years ago

It's not working

Just follow the my guide above. It's simple and it works without having to alter your vendor folder (which you really shouldn't do).

zekinder commented 5 years ago

Add use App\Notifications\ResetPassword; to the top of the file (ie. a link to the notification you just created)

Thanks for this great and proper solution that works for me in 5.8.

Just a note : personnaly i needed to call use App\Notifications\ResetPassword as ResetPasswordNotification; instead to properly call the new notification instead of the vendor one.

beerwin commented 5 years ago

I may be mistaken, but asking for an e-mail address in the password change form itself allows the form to be brute-forced (even if it's throttled). Basically, if someone guesses an e-mail address, will be able to get access to the site.

Asking for an e-mail address in the form defeats the purpose of the token.

In the current state of things, the token is useless (it only tells the router to load a different controller method if it is present).

I went on a solution where I overrode the trait in the controller itself, and retrieved the e-mail address with a token. This comes with a slight performance impact, as it needs to go through the password reset table to match the token, but on the site it's going to be used, it won't matter.

devcircus commented 5 years ago

It doesn't defeat the purpose. Just another level, maybe unnecessary, of security. The email must match the email that initiated the reset an the token must match the token stored against the email address. Brute force isn't possible in this case.

JohnnyWalkerDigital commented 5 years ago

There’s a token which needs to match the email address. Having an email address isn’t enough to change someone’s password.

On 3 May 2019, at 12:16, Ervin Nagy notifications@github.com wrote:

I may be mistaken, but asking for an e-mail address in the password change form itself allows the form to be brute-forced (even if it's throttled). Basically, if someone guesses an e-mail address, will be able to get access to the site.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub, or mute the thread.

mheesters commented 4 years ago

For everyone who doesn't want to touch vendor or create a lot of extra code, there's a small simple dirty JS solution:

var url = window.location.href;
if (url.indexOf("/password/reset/") >= 0) {
    var email = url.substr(url.indexOf('?email=') + 7);
    $("#email").val(decodeURIComponent(email));
}

Checks if you're on the password reset page and copies the email address from the URL in the email address box.