Edited at

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

Laravelでスキャッフォルドされるログイン周りの実装がどうなっているのかを調べてみました。

該当箇所のコードを引用しつつ、コメントを付与する形で解説しています。

なお、ログイン周りをカスタマイズする方法を解説している記事ではないので注意してください。

あくまでLaravel内部でどういう処理が行われているのかを調べた記事になります。

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

関連記事

Laravelの実装調査 ~会員登録編・署名付きリンクもあるよ~

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

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


  • ログイン

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

  • ログアウト

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


ログイン

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


ルーティング

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


Router.php

// ログインフォームの表示

$this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
// ログイン処理
$this->post('login', 'Auth\LoginController@login');


バリデーション

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


AuthenticatesUsers.php

protected function validateLogin(Request $request)

{
$request->validate([
$this->username() => 'required|string',
'password' => 'required|string',
]);
}


処理概要

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


AuthenticatesUsers.php

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行目あたり


SessionGuard.php

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行目あたり


SessionGuard.php

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行目あたり


SessionGuard.php

    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行目あたり


GenericUser.php

public function getRememberTokenName()

{
return 'remember_token';
}


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

リメンバーログイン(後述)は、DBのリメンバートークンクッキーのリメンバートークンを照合する。

ここではパイプ区切りユーザID, リメンバートークン, パスワードをクッキーに保存している。

(実際にはクッキーに保存するためのキューを発行している?)

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


SessionGuard.php

protected function queueRecallerCookie(AuthenticatableContract $user)

{
$this->getCookieJar()->queue($this->createRecaller(
$user->getAuthIdentifier().'|'.$user->getRememberToken().'|'.$user->getAuthPassword()
));
}

クッキー名はデフォルトではremember_session_[ハッシュ]となっている。

ハッシュが付与されているのはコードがバージョンアップした際に古いバージョンで生成したクッキーを読み込まないためだろうか。

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


SessionGuard.php

public function getRecallerName()

{
return 'remember_'.$this->name.'_'.sha1(static::class);
}


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

試行回数はキャッシュドライバ経由でキャッシュ上に保存されている。

なので、物理的な保存先は.envのCACHE_DRIVERで切り替えられる。

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


ThrottlesLogin.php

protected function incrementLoginAttempts(Request $request)

{
// このlimiterがCacheクラスのラッパーになっている。
$this->limiter()->hit(
$this->throttleKey($request), $this->decayMinutes()
);
}

試行回数をキャッシュ上に保存する際のキー名はユーザ名|IPアドレスとなっている。

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

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


ThrottlesLogin.php

protected function throttleKey(Request $request)

{
return Str::lower($request->input($this->username())).'|'.$request->ip();
}

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


RateLimiter.php

$this->cache->add(

$key.':timer', $this->availableAt($decayMinutes * 60), $decayMinutes
);


ログイン状態のチェック

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


処理概要

おおまかには以下の通り



  1. セッションに保存されているユーザIDがあれば、DBからユーザ情報を取得し、ログイン済みとする。

  2. セッションに保存されているユーザIDがなければ、クッキーのリメンバートークンから再ログインを試みる。

  3. いずれも出来ない場合は、未ログインとしてリダイレクトされる。

以下、コードを追いかけている。

Illuminate\Auth\Middleware\Authenticate.php 55行目あたり


Authenticate.php

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行目あたり


GuardHelper.php

public function check()

{
// userメソッドの返り値をチェックしている(次のコードスニペットを参照)
return ! is_null($this->user());
}

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


SessionGuard.php

    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行目あたり


SessionGuard.php

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行目あたり


Router.php

$this->post('logout', 'Auth\LoginController@logout')->name('logout');



バリデーション

なし


処理概要

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


SessionGuard.php

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


おわりに

以上、ログイン周りの実装調査でした。

何かしら新しい発見があったなら幸いです。

私としては、曖昧だったリメンバートークン周りが整理できたのは良かったかなぁと思っております。

クッキーに保存するリメンバーログインのための情報としてパスワードを含める必要はあるのか?とも思ったり。

手が空いたらまた調べたい所ですね。