Laravel 重置密码发送邮件分析

说明

Laravel 内置了发送邮件重置密码的功能,本文分析其发送请求重置密码邮件的功能,了解其执行流程。首先,假设我们已经有一个大概长这样的User模型:

.
.
.
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
    use Notifiable;
    .
    .
    .
}

一个必要特征是继承Illuminate\Foundation\Auth\User类,并且引入Notifiable这个 trait。

流程分析

从路由:

Route::post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email')

定位到控制器:app\Http\Controllers\Auth\ForgotPasswordController.php,发起重置密码请求的操作方法在其引入的SendsPasswordResetEmails trait 中,具体代码:

public function sendResetLinkEmail(Request $request)
{
    //验证输入的参数
    $this->validateEmail($request);

    $response = $this->broker()->sendResetLink(
        $this->credentials($request)
    );

    return $response == Password::RESET_LINK_SENT
                ? $this->sendResetLinkResponse($request, $response)
                : $this->sendResetLinkFailedResponse($request, $response);
}

主要的逻辑都在中间的一句。

$this->broker() 分析

首先,逻辑从$this->broker()开始。broker方法:

public function broker()
{
    // Password 是 Illuminate\Support\Facades\Password.php类
    // 该类是Facade类
    // dd(get_class(resolve('auth.password')))打印出其对应的实现类是:
    // Illuminate\Auth\Passwords\PasswordBrokerManager
    return Password::broker();
}

真正的实现在 Illuminate\Auth\Passwords\PasswordBrokerManager类的broker方法:

public function broker($name = null)
{
    $name = $name ?: $this->getDefaultDriver();
    return $this->brokers[$name] ?? ($this->brokers[$name] = $this->resolve($name));
}

由于前面的$name没有传值,所以会先执行$this->getDefaultDriver()获取$name:

public function getDefaultDriver()
{
    // app['config']['auth.defaults.passwords'] == 'user'
    return $this->app['config']['auth.defaults.passwords'];
}

因此默认情况下,$name的值为users。
接下来,由于$this->brokers[$name]还没有值,所以调用后面的resolve方法,其代码如下:

protected function resolve($name)
{
    // A
    $config = $this->getConfig($name);

    if (is_null($config)) {
        throw new InvalidArgumentException("Password resetter [{$name}] is not defined.");
    }
    // B
    return new PasswordBroker(
        $this->createTokenRepository($config),
        $this->app['auth']->createUserProvider($config['provider'] ?? null)
    );
}

该方法主要做个两个操作,其一是获取配置:

protected function getConfig($name)
{
    return $this->app['config']["auth.passwords.{$name}"];
}

可以看出,得到的配置是 auth.php文件中,auth.passwords.users 键的值(各值的作用见注释):

 [
        'provider' => 'users',  // 数据提供者
        'table' => 'password_resets', //保存token的表
        'expire' => 60, //token过期时间
]

其二是实例化一个Illuminate\Auth\Passwords\PasswordBroker类的实例。注意到实例化的时候传入的两个参数,这两个参数分别是:

  • token 仓库类

    打印一下第一个参数,$this->createTokenRepository($config) 的值,得到:
    源码分析:重置密码发送邮件原理
    其中,$table 属性的值为password_resets,即存放token的数据表名称,该值来自我们的 auth.php 配置文件,因此,我们可以根据实际需要修改存放token的表名,同理,也可以配置token的过期时间。

  • 数据提供类

    第二个参数,$this->app['auth']->createUserProvider($config['provider'] ?? null),默认情况下是一个EloquentUserProvider类:

    源码分析:重置密码发送邮件原理
    语句中的 createUserProvider 方法位于 AuthManager 类引入的CreatesUserProviders trait 中,其主要逻辑是:读取auth.php文件中的provider.users的值,然后根据获取到的驱动去创建驱动实例,一般有database和eloquent驱动,默认是使用eloquent驱动。

最终 $this->broker() 得到的值为:

源码分析:重置密码发送邮件原理
一个PasswordBroker类的实例。

sendResetLink 方法

得到 PasswordBroker 类的实例后,程序接着调用其旗下的 sendResetLink 方法:

public function sendResetLink(array $credentials)
{
    # A
    $user = $this->getUser($credentials);
    if (is_null($user)) {
        return static::INVALID_USER;
    }
    # B
    $user->sendPasswordResetNotification(
        $this->tokens->create($user)
    );
    return static::RESET_LINK_SENT;
}

A)根据传入的参数$credentials查找对应用户并创建模型

再看一下开头的代码片段:

$response = $this->broker()->sendResetLink(
    $this->credentials($request)
);

sendResetLink 传入的参数值为:$this->credentials($request),其credentials方法的返回值为:$request->only('email'), 然后该值传递给getUser方法,从而获得 user 模型。由此可知,默认是使用邮箱查找用户。如果我们想要使用的是用户名查找,可以将$this->credentials($request)替换为$request->only('username')

B)sendPasswordResetNotification 方法

我们的邮件是如何发出去的,都要在这里展开来分析。首先,先来分析传入的参数。它接收的参数为$this->tokens->create($user)$this->tokens为前面获取到的token仓库类DatabaseTokenRepository,该类旗下的 create 方法:

public function create(CanResetPasswordContract $user)
{
    # 获取用户的email
    $email = $user->getEmailForPasswordReset();
    # 删除password_resets表中的对应记录
    $this->deleteExisting($user);
    # 创建新的token:hash_hmac('sha256', Str::random(40), $this->hashKey)
    # 传入的 $this->hashKey 是来自 .env 文件的 APP_KEY
    $token = $this->createNewToken();
    # 将token数据保存到password_resets表
    $this->getTable()->insert($this->getPayload($email, $token));
    # 最后将创建的token返回
    return $token;
}

由此以上代码可知,在获得token的过程中,程序还带做了另外几件事:1. 删除password_resets表中的对应记录(如果有的话);2.创建token并保存到password_resets表。

得到 token 后,我们就可以着手分析sendPasswordResetNotification方法了。该方法位于Illuminate\Foundation\Auth\User类引入的CanResetPassword trait 中(从最开头的代码片段可以看出,User模型继承了Illuminate\Foundation\Auth\User类,所以拥有该方法的),该方法具体实现:

public function sendPasswordResetNotification($token)
{
    $this->notify(new ResetPasswordNotification($token));
}

首先,我们先看传入的参数,是一个ResetPassword类的实例,该类继承了Notification类,打印下传入的参数,是这样子的:

Laravel 重置密码发送邮件分析

接着,我们来分析notify方法,它位于我们创建的User模型引入的Notifiable trait 中,而实际又是在Notifiable trait 引入的RoutesNotifications trait 中:

public function notify($instance)
{
    # Dispatcher::class 对应 Illuminate\Contracts\Notifications\Dispatcher接口
    app(Dispatcher::class)->send($this, $instance);
}

Dispatcher::class 只是一个接口类,那么它的具体实现是哪个类呢?由Illuminate\Notifications\RoutesNotifications 类定位到其所在的文件夹Notifications,在这个文件夹中,有一个服务提供者NotificationServiceProvider,正是在这里定义了Dispatcher::class由哪个类来实现,定义代码如下:

public function register()
{
    //B ChannelManager::class的实现绑定为 ChannelManager 类的实例
    $this->app->singleton(ChannelManager::class, function ($app) {
        return new ChannelManager($app);
    });
    // A 将Dispatcher接口类别名设为ChannelManager::class
    $this->app->alias(
        ChannelManager::class, DispatcherContract::class
    );

    $this->app->alias(
        ChannelManager::class, FactoryContract::class
    );
}

这里倒过来看,先看A语句设置了别名,在看B语句绑定接口到一个实例。所以,app(Dispatcher::class)->send($this, $instance);中的send方法是属于ChannelManager::class类的,其实现如下:

public function send($notifiables, $notification)
{
    return (new NotificationSender(
        $this, $this->container->make(Bus::class), $this->container->make(Dispatcher::class), $this->locale)
    )->send($notifiables, $notification);
}

该方法接收了两个参数,第一个是被通知的对象,比如这里是传入User模型,第二个是消息实例(通知的内容),从前面分析可知,这里是传入了一个ResetPassword类的实例。该方法实例化了NotificationSender类并调用其send方法,让我们跳转到这个send方法:

public function send($notifiables, $notification)
{
    // 将被通知对象格式化成模型集合
    $notifiables = $this->formatNotifiables($notifiables);
    // 如果使用队列
    if ($notification instanceof ShouldQueue) {
        return $this->queueNotification($notifiables, $notification);
    }

    return $this->sendNow($notifiables, $notification);
}

该方法主要是格式化了一遍传入的被通知对象,然后调用sendNow方法:

public function sendNow($notifiables, $notification, array $channels = null)
{
    // 将被通知对象格式化成模型集合
    $notifiables = $this->formatNotifiables($notifiables);
    // 克隆通知消息实例
    $original = clone $notification;
    // 检查被通知对象是否有channel,比如database,mail
    // 没有的话就略过,不对其作通知
    // A
    foreach ($notifiables as $notifiable) {
        if (empty($viaChannels = $channels ?: $notification->via($notifiable))) {
            continue;
        }
        // B 设置使用的语言
        $this->withLocale($this->preferredLocale($notifiable, $notification), function () use ($viaChannels, $notifiable, $original) {
            $notificationId = Str::uuid()->toString();
            // C 发送通知到每一个频道
            foreach ((array) $viaChannels as $channel) {
                $this->sendToNotifiable($notifiable, $notificationId, clone $original, $channel);
            }
        });
    }
}

(A) 分析$notification->via($notifiable)。从前面可知,$notificationResetPassword类的实例,所以via方法是其旗下的方法,代码如下:

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

由此可以看出,默认是使用发送邮件的方式。
(B) 分析 preferredLocale 方法。

protected function preferredLocale($notifiable, $notification)
{
    return $notification->locale ?? $this->locale ?? value(function () use ($notifiable) {
        // 如果被通知对象实现了HasLocalePreference接口
        if ($notifiable instanceof HasLocalePreference) {
            return $notifiable->preferredLocale();
        }
    });
}

由以上代码可知,被通知对象可以实现HasLocalePreference接口,从而通过实现preferredLocale方法指定使用的语言。
(C) 分析 sendToNotifiable 方法。

protected function sendToNotifiable($notifiable, $id, $notification, $channel)
{
    if (! $notification->id) {
        $notification->id = $id;
    }
    // 触发通知将要发送的事件,如果返货false,通知将不会被发送
    if (! $this->shouldSendNotification($notifiable, $notification, $channel)) {
        return;
    }
    // $this->manager->driver($channel)将根据传入的$channel创建对应channel类的实例
    // 比如,mail channel,将创建MailChannel类的实例
    // C-1 
    $response = $this->manager->driver($channel)->send($notifiable, $notification);
    // 触发消息已发送的事件
    $this->events->dispatch(
        new Events\NotificationSent($notifiable, $notification, $channel, $response)
    );
}

(C-1) $this->manager->driver($channel)得到的是MailChannel类的实例,程序接着调用它的send方法:

public function send($notifiable, Notification $notification)
{
    // $notification是Illuminate\Auth\Notifications\ResetPassword类的实例
    // C-1-1
    $message = $notification->toMail($notifiable);

    if (! $notifiable->routeNotificationFor('mail', $notification) &&
        ! $message instanceof Mailable) {
        return;
    }
    // 如果实现了Mailable接口
    if ($message instanceof Mailable) {
        return $message->send($this->mailer);
    }
    // 发送邮件
    $this->mailer->send(
        $this->buildView($message),
        array_merge($message->data(), $this->additionalMessageData($notification)),
        $this->messageBuilder($notifiable, $notification, $message)
    );
}

(C-1-1) Illuminate\Auth\Notifications\ResetPassword类的toMail方法:

public function toMail($notifiable)
{
    if (static::$toMailCallback) {
        return call_user_func(static::$toMailCallback, $notifiable, $this->token);
    }

    return (new MailMessage)
        ->subject(Lang::get('Reset Password Notification'))
        ->line(Lang::get('You are receiving this email because we received a password reset request for your account.'))
        ->action(Lang::get('Reset Password'), url(config('app.url').route('password.reset', ['token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset()], false)))
        ->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]))
        ->line(Lang::get('If you did not request a password reset, no further action is required.'));
}

发送邮件通知的内容都在这里设置。$message = $notification->toMail($notifiable);最终得到的值$message如下:

Laravel 重置密码发送邮件分析
最后,根据$message实例的各种属性设置发送邮件,限于篇幅,具体细节就不再分析了。

发送完邮件消息之后,Illuminate\Auth\Passwords\PasswordBroker类的方法返回static::RESET_LINK_SENT。回到开头的sendResetLinkEmail方法:

public function sendResetLinkEmail(Request $request)
{
    $this->validateEmail($request);

    // 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.

    $response = $this->broker()->sendResetLink(
        $this->credentials($request)
    );

    return $response == Password::RESET_LINK_SENT
                ? $this->sendResetLinkResponse($request, $response)
                : $this->sendResetLinkFailedResponse($request, $response);
}

由于$response == Password::RESET_LINK_SENT为true,所以执行$this->sendResetLinkResponse($request, $response)

protected function sendResetLinkResponse(Request $request, $response)
{
    return back()->with('status', trans($response));
}

所以发送消息成功后,页面后退,同时带上消息发送成功的信息。

参考

本作品采用《CC 协议》,转载必须注明作者和本文链接
Was mich nicht umbringt, macht mich stärker
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 7

lv源码 太复杂了

4年前 评论

@_杭城浪子 是的,复杂,封装得非常精细,分析下去,如翻山越岭,一山又有一山,令人望而生畏,随时想放弃分析。但可能了解它的一些“套路”,能够从Facade类和接口找到真正的实现类,就会好点。1.很多类拆散成trait,方法要到trait里面去找;2. 注意对应功能的服务提供者,在boot和register方法中都提前做了哪些操作和绑定。

4年前 评论
wangchunbo

就是很想知道,那些写这些代码的人事如何想的。。。

4年前 评论

@kuibatian 有时候看到想打人:cry:

4年前 评论

看的想吐

3年前 评论

这个发送出去的token跟password_resets表中的token不一样,后续是怎么验证哪个用户重置密码的?

2年前 评论
myoppo 5个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!