Laravelの実装調査　～パスワードリセット編～

Laravelでスキャッフォルドされるパスワードリセット周りの実装がどうなっているのかを調べてみました。
該当箇所のコードを引用しつつ、コメントを付与する形で解説しています。
なお、パスワードリセット周りをカスタマイズする方法を解説している記事ではないので注意してください。
あくまでLaravel内部でどういう処理が行われているのかを調べた記事になります。

調査に使用したLaravelのバージョンは`5.7.22`です。

**関連記事**
[Laravelの実装調査　～会員登録編・署名付きリンクもあるよ～](https://qiita.com/well/items/f01b74036d39cb8cb4a0)
[Laravelの実装調査　～ログイン編・リメンバーログインもあるよ～](https://qiita.com/well/items/eb0d2c1970dfab5f6abe)

今回はパスワードリセット編という事で、以下２ケースをまとめています。

* パスワードリセット（メール送信）
* パスワードリセット（変更処理）

それでは見ていきましょう。

# パスワードリセット（メール送信）

パスワードを忘れてログイン出来なくなったユーザ向けに、パスワード変更メールを送信する処理です。

## ルーティング

Illuminate\Routing\Router.php 1158行目あたり

```php:Router.php:1158
// パスワードリセットメール送信フォーム表示
$this->get('password/reset', 'Auth\ForgotPasswordController@showLinkRequestForm')->name('password.request');
// パスワードリセットメール送信
$this->post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email');
```

## バリデーション

Illuminate\Foundation\Auth\SendsPasswordResetEmails.php 48行目あたり

```php:SendsPasswordResetEmails.php:48
protected function validateEmail(Request $request)
{
    $this->validate($request, ['email' => 'required|email']);
}
```

## 処理概要

Illuminate\Auth\Passwords\PasswordBroker.php 55行目あたり

```php:PasswordBroker.php:55
    public function sendResetLink(array $credentials)
    {
        // メールアドレスからユーザ情報を取得
        $user = $this->getUser($credentials);

        if (is_null($user)) {
            return static::INVALID_USER;
        }

        // パスワードリセットトークンを生成し
        // パスワードリセットメールを送信する
        $user->sendPasswordResetNotification(
            $this->tokens->create($user)
        );

        return static::RESET_LINK_SENT;
    }
```

## メールの内容

名前付きルート`password.reset`へのリンクを生成しているのがわかる。
その際、トークンをルートパラメータとして付与している。
生成されるリンクの書式は`password/reset/{token}`となっている。

Illuminate\Auth\Notifications\ResetPassword.php 53行目あたり

```php:ResetPassword.php:53
public function toMail($notifiable)
{
    if (static::$toMailCallback) {
        return call_user_func(static::$toMailCallback, $notifiable, $this->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('password.reset', $this->token, false)))
        ->line(Lang::getFromJson('If you did not request a password reset, no further action is required.'));
}
```


## パスワードリセットトークンについて

### トークン作成処理概要

Illuminate\Auth\Passwords\DatabaseTokenRepository.php 74行目あたり

```php:DatabaseTokenRepository.php:74
public function create(CanResetPasswordContract $user)
{
    $email = $user->getEmailForPasswordReset();

    // DBにパスワードリセットトークンが存在する場合は削除する
    $this->deleteExisting($user);

    // トークン生成
    $token = $this->createNewToken();

    // DBにトークンを保存する
    $this->getTable()->insert($this->getPayload($email, $token));

    // トークンを呼び出し元に返す。
    // このトークンがメールのリンクに付与される
    return $token;
}
```

### トークンの作り方

ランダムな40文字をAPP_KEYをキーとして`sha256`でハッシュ化している。

Illuminate\Auth\Passwords\DatabaseTokenRepository.php 170行目あたり

```php:DatabaseTokenRepository.php:170
    public function createNewToken()
    {
        // $this->hashKeyはAPP_KEYをbase64_decodeした値
        return hash_hmac('sha256', Str::random(40), $this->hashKey);
    }
```


### トークンを保存するDBのスキーマ

メールアドレスが主キーとなっている。

```php:2014_10_12_100000_create_password_resets_table.php:16
Schema::create('password_resets', function (Blueprint $table) {
    $table->string('email')->index();
    $table->string('token');
    $table->timestamp('created_at')->nullable();
});
```


### 保存される情報

トークンをDBに保存する際、もう一度、ハッシュ化する。
パスワードをDBに保存する際にハッシュ化するのと同じ目的だろうか。
ハッシュ化の方法もパスワードのハッシュ化と同様

Illuminate\Auth\Passwords\DatabaseTokenRepository.php 108行目あたり

```php:DatabaseTokenRepository.php:108
protected function getPayload($email, $token)
{
    return ['email' => $email, 'token' => $this->hasher->make($token), 'created_at' => new Carbon];
}
```



# パスワードリセット（変更処理）

パスワード変更メールに記載されているリンクからパスワードリセットフォームの表示と、実際にパスワードを変更する処理を行います。

## ルーティング

Illuminate\Routing\Router.php 1180行目あたり

```php:Router.php:1180
// パスワードリセットフォーム表示
$this->get('password/reset/{token}', 'Auth\ResetPasswordController@showResetForm')->name('password.reset');
// パスワードリセット処理
$this->post('password/reset', 'Auth\ResetPasswordController@reset')->name('password.update');
```

## バリデーション

ここではバリデーションが２種類ある

1. `Request`クラスによるルールベースのバリデーション
2. `PasswordBroker`クラスによるバリデーション

`Request`クラスによるルールベースのバリデーションは以下の通り

Illuminate\Foundation\Auth\ResetsPasswords.php 67行目あたり

```php:ResetsPasswords.php:67
'token' => 'required',
'email' => 'required|email',
'password' => 'required|confirmed|min:6',
```

`PasswordBroker`クラスによるバリデーションは、主に、以下のチェックを行っている。

* メールアドレスに対応するユーザ情報が存在する事
* トークンが一致している事、有効期限が過ぎていない事

Illuminate\Auth\Passwords\PasswordBroker.php 112行目あたり

```php:PasswordBroker.php:112
protected function validateReset(array $credentials)
{
    // メールアドレスからユーザ情報を取得する
    if (is_null($user = $this->getUser($credentials))) {
        return static::INVALID_USER;
    }

    // ポストされたパスワードのバリデーション
    // デフォルトではバリデーションルールの`required|confirmed|min:6`と同じことをチェックしている
    if (! $this->validateNewPassword($credentials)) {
        return static::INVALID_PASSWORD;
    }

    // ポストされたトークンの有効性チェック（次のコードスニペットを参照）
    if (! $this->tokens->exists($user, $credentials['token'])) {
        return static::INVALID_TOKEN;
    }

    return $user;
}
```

Illuminate\Auth\Passwords\DatabaseTokenRepository.php 120行目あたり

```php:DatabaseTokenRepository.php:120
public function exists(CanResetPasswordContract $user, $token)
{
    $record = (array) $this->getTable()->where(
        'email', $user->getEmailForPasswordReset()
    )->first();

    return $record &&
            // 有効期限が過ぎていないか？
           ! $this->tokenExpired($record['created_at']) &&
            // トークンが一致するか？
             $this->hasher->check($token, $record['token']);
}
```


## 処理概要（パスワードリセットフォーム表示）

パスワードをリセットするためのフォームを表示する。
クエリパラメータから**トークン**と**メールアドレス**を取得し、フォームに埋め込んで返している。
ここではまだバリデーションやトークンの有効性チェックは行っていない。

Illuminate\Foundation\Auth\ResetsPasswords.php 25行目あたり

```php:ResetsPasswords.php:25
public function showResetForm(Request $request, $token = null)
{
    return view('auth.passwords.reset')->with(
        ['token' => $token, 'email' => $request->email]
    );
}
```

## 処理概要（パスワードリセット処理）

実際にパスワードを変更する処理を行う。
パスワードリセットフォームでパスワードを入力＆submitすると呼び出される。

Illuminate\Foundation\Auth\ResetsPasswords.php 38行目あたり

```php:ResetsPasswords.php:38
public function reset(Request $request)
{
    // `Request`クラスによるルールベースのバリデーション
    $request->validate($this->rules(), $this->validationErrorMessages());

    // パスワード変更を試みる（次のコードスニペットを参照）
    // ユーザ情報の存在チェックやトークンの有効性チェックを行う
    // 問題無ければ第二引数のコールバックが呼び出され、DBのユーザ情報（パスワード）を更新する
    $response = $this->broker()->reset(
        $this->credentials($request), function ($user, $password) {
        
            // DB上のユーザ情報（パスワード）を更新する
            // リメンバートークンの更新やログイン処理も含む（後述）
            $this->resetPassword($user, $password);
        }
    );

    return $response == Password::PASSWORD_RESET
                ? $this->sendResetResponse($request, $response)
                : $this->sendResetFailedResponse($request, $response);
}
```

Illuminate\Auth\Passwords\PasswordBroker.php 83行目あたり

```php:PasswordBroker.php:83
public function reset(array $credentials, Closure $callback)
{
    // ユーザ情報取得＆バリデーション（次のコードスニペットを参照）
    $user = $this->validateReset($credentials);

    if (! $user instanceof CanResetPasswordContract) {
        return $user;
    }

    $password = $credentials['password'];

    // コールバック呼び出し
    $callback($user, $password);

    // DB上のパスワードリセットトークンを削除
    $this->tokens->delete($user);

    return static::PASSWORD_RESET;
}
```


## DB上のユーザ情報（パスワード）を更新する処理

Illuminate\Foundation\Auth\ResetsPasswords.php 103行目あたり

```php:ResetsPasswords.php:103
    protected function resetPassword($user, $password)
    {
        // パスワードプロパティに新しいパスワードを設定
        $user->password = Hash::make($password);

        // リメンバートークンを更新（ログイン状態維持を解除）
        $user->setRememberToken(Str::random(60));

        // ユーザ情報を保存
        $user->save();

        event(new PasswordReset($user));

        // ログインする
        $this->guard()->login($user);
    }
```

# 今回登場したクラス達

```
Illuminate			
├ Auth		
│ ├ Notifications	
│ │ └ ResetPassword
│ └ Passwords	
│ 　 ├ DatabaseTokenRepository
│ 　 └ PasswordBroker
├ Foundation		
│ └ Auth	
│ 　 ├ ResetsPasswords
│ 　 └ SendsPasswordResetEmails
└ Routing		
　 └ Router	
```




# おわりに

以上、パスワードリセット周りの実装調査でした。
何かしら新しい発見があったなら幸いです。