Laravel 重置密碼傳送郵件分析

tsin發表於2019-10-27

說明

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));
}

所以傳送訊息成功後,頁面後退,同時帶上訊息傳送成功的資訊。

參考

Was mich nicht umbringt, macht mich stärker

相關文章