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
类,打印下传入的参数,是这样子的:
接着,我们来分析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)
。从前面可知,$notification
是ResetPassword
类的实例,所以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
如下:
最后,根据$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));
}
所以发送消息成功后,页面后退,同时带上消息发送成功的信息。