Laravelの実装調査　～ログイン編・リメンバーログインもあるよ～

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

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

**関連記事**
[Laravelの実装調査　～会員登録編・署名付きリンクもあるよ～](https://qiita.com/well/items/f01b74036d39cb8cb4a0)
[Laravelの実装調査　～パスワードリセット編～](https://qiita.com/well/items/aa906cd07e54037a5eaf)

今回はログイン編という事で、以下２ケースをまとめています。

* ログイン
* ログイン状態のチェック
* ログアウト

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

# ログイン

ポストされたメールアドレスとパスワードを基にユーザを識別する処理です。

## ルーティング

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

```php:Router.php:1150
// ログインフォームの表示
$this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
// ログイン処理
$this->post('login', 'Auth\LoginController@login');
```


## バリデーション

Illuminate\Foundation\Auth\AuthenticatesUsers.php 64行目あたり

```php:AuthenticatesUsers.php:64
protected function validateLogin(Request $request)
{
    $request->validate([
        $this->username() => 'required|string',
        'password' => 'required|string',
    ]);
}
```


## 処理概要

Illuminate\Foundation\Auth\AuthenticatesUsers.php 31行目あたり

```php:AuthenticatesUsers.php:31
public function login(Request $request)
{
    // バリデーション
    $this->validateLogin($request);

    // ログイン試行回数チェック
    if ($this->hasTooManyLoginAttempts($request)) {
        $this->fireLockoutEvent($request);

        return $this->sendLockoutResponse($request);
    }

    // ログイン試行（次のコードスニペットを参照）
    if ($this->attemptLogin($request)) {
        return $this->sendLoginResponse($request);
    }

    // ログイン試行回数を増やす
    $this->incrementLoginAttempts($request);

    return $this->sendFailedLoginResponse($request);
}
```

Illuminate\Auth\SessionGuard.php 345行目あたり

```php:SessionGuard.php:345
public function attempt(array $credentials = [], $remember = false)
{
    $this->fireAttemptEvent($credentials, $remember);

    // ユーザ情報取得
    $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

    // 認証データ照合
    if ($this->hasValidCredentials($user, $credentials)) {
        // ログイン（次のコードスニペットを参照）
        $this->login($user, $remember);

        return true;
    }

    $this->fireFailedEvent($user, $credentials);

    return false;
}
```

Illuminate\Auth\SessionGuard.php 405行目あたり


```php:SessionGuard.php:405
public function login(AuthenticatableContract $user, $remember = false)
{
    // セッション情報更新
    $this->updateSession($user->getAuthIdentifier());

    // ログイン状態を維持する場合（リメンバーミーがON）
    if ($remember) {
        // DBのリメンバートークンを更新する
        $this->ensureRememberTokenIsSet($user);

        // クッキーにリメンバートークンを登録する
        $this->queueRecallerCookie($user);
    }

    $this->fireLoginEvent($user, $remember);

    $this->setUser($user);
}
```

## リメンバートークンの生成＆DBへの保存

`Str::random(60)`で生成している。
Illuminate\Auth\SessionGuard.php 527行目あたり

```php:SessionGuard.php:527
    protected function cycleRememberToken(AuthenticatableContract $user)
    {
        // リメンバートークンをプロパティにセット
        $user->setRememberToken($token = Str::random(60));

        // DBに保存
        $this->provider->updateRememberToken($user, $token);
    }
```

DB保存時のカラム名はデフォルトでは`remember_token`となっている。
Illuminate\Auth\GenericUser.php 85行目あたり

```php:GenericUser.php:85
public function getRememberTokenName()
{
    return 'remember_token';
}
```


## リメンバートークンをクッキーへ保存

リメンバーログイン（後述）は、**DBのリメンバートークン**と**クッキーのリメンバートークン**を照合する。
ここでは**パイプ区切り**で`ユーザID`, `リメンバートークン`, `パスワード`をクッキーに保存している。
（実際にはクッキーに保存するためのキューを発行している？）

Illuminate\Auth\SessionGuard.php 458行目あたり

```php:SessionGuard.php:458
protected function queueRecallerCookie(AuthenticatableContract $user)
{
    $this->getCookieJar()->queue($this->createRecaller(
        $user->getAuthIdentifier().'|'.$user->getRememberToken().'|'.$user->getAuthPassword()
    ));
}
```

クッキー名はデフォルトでは`remember_session_[ハッシュ]`となっている。
ハッシュが付与されているのはコードがバージョンアップした際に古いバージョンで生成したクッキーを読み込まないためだろうか。

Illuminate\Auth\SessionGuard.php 659行目あたり

```php:SessionGuard.php:659
public function getRecallerName()
{
    return 'remember_'.$this->name.'_'.sha1(static::class);
}
```


## ログイン試行回数チェックについて

試行回数はキャッシュドライバ経由でキャッシュ上に保存されている。
なので、物理的な保存先は.envのCACHE_DRIVERで切り替えられる。

Illuminate\Foundation\Auth\ThrottlesLogin.php 33行目あたり

```php:ThrottlesLogin.php:33
protected function incrementLoginAttempts(Request $request)
{
    // このlimiterがCacheクラスのラッパーになっている。
    $this->limiter()->hit(
        $this->throttleKey($request), $this->decayMinutes()
    );
}
```

試行回数をキャッシュ上に保存する際のキー名は`ユーザ名|IPアドレス`となっている。
試行回数超過時にいつまで拒否するかを表すタイムスタンプのキー名は`ユーザ名|IPアドレス:timer`となっている。

Illuminate\Foundation\Auth\ThrottlesLogin.php 87行目あたり

```php:ThrottlesLogin.php:87
protected function throttleKey(Request $request)
{
    return Str::lower($request->input($this->username())).'|'.$request->ip();
}
```

Illuminate\Cache\RateLimiter.php 59行目あたり

```php:RateLimiter.php:59
$this->cache->add(
    $key.':timer', $this->availableAt($decayMinutes * 60), $decayMinutes
);
```


# ログイン状態のチェック

`auth`ミドルウェアでは、アクセスしたユーザがログイン済みかどうかを調べて、未ログインであればログインページにリダイレクトさせます。

## 処理概要

おおまかには以下の通り

1. **セッションに保存されているユーザID**があれば、DBからユーザ情報を取得し、ログイン済みとする。
2. セッションに保存されているユーザIDがなければ、クッキーの**リメンバートークン**から再ログインを試みる。
3. いずれも出来ない場合は、未ログインとしてリダイレクトされる。

以下、コードを追いかけている。
Illuminate\Auth\Middleware\Authenticate.php 55行目あたり

```php:Authenticate.php:55
protected function authenticate($request, array $guards)
{
    if (empty($guards)) {
        $guards = [null];
    }

    foreach ($guards as $guard) {
    
        // ログインしているか？（次のコードスニペットを参照）
        if ($this->auth->guard($guard)->check()) {
        
            // Authファサードに、使うべきガードを指示して処理を抜ける
            return $this->auth->shouldUse($guard);
        }
    }

    // ログインしていない場合は、例外を吐く
    throw new AuthenticationException(
        'Unauthenticated.', $guards, $this->redirectTo($request)
    );
}
```

Illuminate\Auth\GuardHelper.php 58行目あたり

```php:GuardHelper.php:58
public function check()
{
    // userメソッドの返り値をチェックしている（次のコードスニペットを参照）
    return ! is_null($this->user());
}
```

Illuminate\Auth\SessionGuard.php 113行目あたり

```php:SessionGuard.php:113
    public function user()
    {
        if ($this->loggedOut) {
            return;
        }

        if (! is_null($this->user)) {
            return $this->user;
        }

        // セッションからユーザIDを取得
        $id = $this->session->get($this->getName());

        // セッションのユーザIDからユーザ情報取得
        if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
            $this->fireAuthenticatedEvent($this->user);
        }

        // セッションからユーザ情報が取得できない場合
        // Cookieのリメンバートークンでログインを試みる
        if (is_null($this->user) && ! is_null($recaller = $this->recaller())) {
          
            // リメンバートークンを用いたログイン処理
            // （次のコードスニペット参照）
            $this->user = $this->userFromRecaller($recaller);

            if ($this->user) {
                $this->updateSession($this->user->getAuthIdentifier());

                $this->fireLoginEvent($this->user, true);
            }
        }

        return $this->user;
    }
```

Illuminate\Auth\SessionGuard.php 159行目あたり

```php:SessionGuard.php:159
protected function userFromRecaller($recaller)
{
    if (! $recaller->valid() || $this->recallAttempted) {
        return;
    }

    $this->recallAttempted = true;

    // クッキーに保存されていたリメンバーログインのための情報から
    // ユーザIDとリメンバートークンを取り出し
    // それらにマッチするユーザ情報を取得する
    $this->viaRemember = ! is_null($user = $this->provider->retrieveByToken(
        $recaller->id(), $recaller->token()
    ));

    return $user;
}
```


# ログアウト

セッションやクッキーからユーザ情報を削除し、ログインしていない状態にします。

## ルーティング

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

```php:Router.php:1149
$this->post('logout', 'Auth\LoginController@logout')->name('logout');
```

## バリデーション

なし

## 処理概要

Illuminate\Auth\SessionGuard.php 481行目あたり

```php:SessionGuard.php:481
public function logout()
{
    $user = $this->user();

    // セッションとクッキーの情報を削除
    $this->clearUserDataFromStorage();

    // DBのリメンバートークンを更新する
    // クッキーのリメンバートークンは削除されているので、更新後のトークンが使われる事はない。
    if (! is_null($this->user)) {
        $this->cycleRememberToken($user);
    }

    if (isset($this->events)) {
        $this->events->dispatch(new Events\Logout($this->name, $user));
    }

    $this->user = null;

    $this->loggedOut = true;
}
```

# 今回登場したクラス達

```
Illuminate			
├ Auth		
│ ├ GenericUser	
│ ├ GuardHelper	
│ ├ Middleware	
│ │ └ Authenticate
│ └ SessionGuard	
├ Cache		
│ └ RateLimiter	
├ Foundation		
│ └ Auth	
│ 　 ├ AuthenticatesUsers
│ 　 └ ThrottlesLogin
└ Routing		
　 └ Router	
```


# おわりに

以上、ログイン周りの実装調査でした。
何かしら新しい発見があったなら幸いです。

私としては、曖昧だったリメンバートークン周りが整理できたのは良かったかなぁと思っております。
クッキーに保存するリメンバーログインのための情報としてパスワードを含める必要はあるのか？とも思ったり。
手が空いたらまた調べたい所ですね。