Laravel8では認証にJetStream, Breezeが追加されましたが内部で行われているログイン処理はこれまでのバージョンと変わりません。本文書はLaravel6で動作確認したものですがLaravel7, Laravel8, Laravel9でも役に立つ知識です。最新版のLaravel8ではユーザの画面やルーティングの設定方法などに変更がありますがコアの部分は変わりません。
fukidashi

Laravelでは認証機能が事前に用意されているのでLaravelのインストール直後からログイン認証を行うことができます。すぐに使えるものなので認証機能がどのような流れで行われているかを確認せずに使っている人も多いかと思います。今回はLaravel6.Xのコードを使ってログインが完了するまでの流れを実際に動作とコードベースで確認していきます。

読んで頂いた人の時間の無駄にならないように説明しているつもりですがコードで処理を確認するためには複数のファイルを横断して行わなれけれならないためいつも以上に読みにくいかもしれませんのでご注意ください。

Laravelのログイン認証全般を理解するには下記の文書が参考になります。

マルチ認証を行いたい人は下記の文書が参考になります。

ログインの確認

Laravelインストール後、認証機能を設定すると画面右上にLOGIN, REGISTERへのリンクが作成されます。

認証機能作成後のトップページ
認証機能作成後のトップページ

LOGINのリンクをクリックするとログインの入力フォームが表示されます。ログイン画面のE-Mail AddressとPasswordを入れた後Loginボタンを押すと処理が開始されます。今回はこのボタンを押した後の流れを確認していきます。

ログインするためには事前にユーザの登録(Register)は終わらせておく必要があります。
fukidashi
Laravelログイン画面
Laravelログイン画面

ルーティングの確認

ログイン画面が表示されるURLの/loginのルーティングをルーティングファイルであるweb.phpで確認しても見つけることができません。理由は認証機能に関するルーティングがAuth::routes()にまとめられているためです。


Auth::routes();

Auth::routes()はIlluminate\Support\Facades\Auth.phpファイルのroutesメソッドで、そのメソッドの中でstatic::$app->make(‘router’)->auth($options)が実行されます。


class Auth extends Facade
{
    /**
     * Get the registered name of the component.
     *
     * @return string
     */
    protected static function getFacadeAccessor()
    {
        return 'auth';
    }

    /**
     * Register the typical authentication routes for an application.
     *
     * @param  array  $options
     * @return void
     */
    public static function routes(array $options = [])
    {
        static::$app->make('router')->auth($options);
    }
}

static::$app->make(‘router’)->auth($options)のauthメソッドはIlluminate\Routing\Router.phpに記述されています。ルーティングにはloginだけではなくlogoutやregisterも含まれています。


    public function auth(array $options = [])
    {

        // Authentication Routes...
        $this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
        $this->post('login', 'Auth\LoginController@login');
        $this->post('logout', 'Auth\LoginController@logout')->name('logout');

        // Registration Routes...
        if ($options['register'] ?? true) {
            $this->get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
            $this->post('register', 'Auth\RegisterController@register');
        }

        // Password Reset Routes...
        if ($options['reset'] ?? true) {
            $this->resetPassword();
        }

        // Password Confirmation Routes...
        if ($options['confirm'] ??
            class_exists($this->prependGroupNamespace('Auth\ConfirmPasswordController'))) {
            $this->confirmPassword();
        }

        // Email Verification Routes...
        if ($options['verify'] ?? false) {
            $this->emailVerification();
        }
    }

設定されているルーティングの確認にはphp artisan route:listコマンドで確認することができます。

コマンドによる認証に関するルーティングの確認
コマンドによる認証に関するルーティングの確認

複数のルーティングが登録されていますが今回はログインの処理の流れを確認するため注目するのはloginへのpostリクエストのみです。


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

loginへのpostリクエストでAuth\LoginContoller@loginメソッドが実行されるので、LoginControllerの中身を確認していきます。

LoginControllerファイル

loginメソッドの確認

Auth\LoginCotroller.phpファイルを開いて中身を確認してもloginメソッドを見つけることができませんがloginメソッドを見つける前にコンストラクターでmiddlewareのguestが設定されているのでその処理を確認しておきます。


namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;

class LoginController extends Controller
{
    use AuthenticatesUsers;

    protected $redirectTo = '/home';

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }
}

middleware guestの処理

middlewareの設定はApp\Http\Kernel.phpに記述されているので、Kernel.phpファイルを開くとmiddlewareのguestを見つけることができます。


    protected $routeMiddleware = [
   ・
   ・

        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
   ・
   ・
    ];

Kernel.phpファイルからguestの処理はRedirectIfAuthenticated.phpファイルに記述されていることがわかります。ファイル名は処理の名前をそのまま表しているものもありファイル名のRedirectIfAuthenticatedはもし認証が完了しているならばリダイレクトを行うという意味を持っています。

middlewareではhandleメソッドが実行されるという決まりがあるのでRedirectIfAuthenticated.phpファイルのhandleメソッドを確認して意味通りの処理が行われているのか確認します。


public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->check()) {
        return redirect('/home');
    }

    return $next($request);
}

handleメソッドの中ではAuth::guard($guard)->check()で分岐が行われています。Authの実体はIlluminate\Auth\AuthManagerでguard()メソッドを実行するとSessionGuardインスタンスが作成されます。Auth::guard($guard)->check()はSessionGuardのcheckメソッドを実行しており認証が完了している場合は/homeにリダイレクトが行われます。ここではまだ認証は完了していないのでリダイレクトが行われず、次のmiddlewareの処理につながる$nextへ$requestが渡されます。

AuthenticatesUsersでの処理

middlewareのguestの処理を確認したので再度Auth\LoginController.phpに戻ってloginメソッドがどこに存在しているのか確認していきます。LoginController.phpファイルには、traitのAuthenticatesUsersが利用されているのでlluminate\Foundation\Auth\AuthenticatesUsersファイルを確認します。loginメソッドはAuthenticatedUsers.phpファイルで見つけることができます。


use AuthenticatesUsers;

AutheticateUsers.phpファイルのloginメソッドには下記の処理が記述されており、処理は大きく5つのパートにわけることができます。一番重要なのが3番目のattemptLoginメソッドですが、各パートについては1つずつ確認を行っていきます。


    public function login(Request $request)
    {
    //1.バリデーション
        $this->validateLogin($request);

    //2.ログイン回数のチェック
        if (method_exists($this, 'hasTooManyLoginAttempts') &&
            $this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

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

    //3.ログインの処理
        if ($this->attemptLogin($request)) {
            return $this->sendLoginResponse($request);
        }

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

     //5.ログイン失敗のレスポンス
       return $this->sendFailedLoginResponse($request);
    }

バリデーションの処理

validateLoginメソッドでは、入力したメールアドレスとパスワードが正しい形式になっているかのチェックを行います。どちらも必須で文字列であることが条件です。ログイン時の入力項目であるemailという文字列がありませんが、$this->username()メソッドを実行するとemailが戻されます。email以外の項目でもチェックできるようにemailではなくusernameメソッドを追加しています。もし、email以外の入力項目を認証に使う場合はusernameメソッドでreturnする文字列を変更することで実現できます。

このバリデーションに失敗するとログイン画面にエラーメッセージが表示されます。


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

public function username()
{
    return 'email';
}
ログインの入力が間違っていた場合
ログインの入力が間違っていた場合

ログイン回数のチェック

ログイン回数のチェックがhasTooManyLoginAttemptsメソッドで行われます。Laravelではログインの失敗回数をキャッシュに保存しています。


if (method_exists($this, 'hasTooManyLoginAttempts') &&
    $this->hasTooManyLoginAttempts($request)) {

  //EVENTを発生させているため、Listenすることが可能
    $this->fireLockoutEvent($request);

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

hasTooManyLoginAttemptsメソッドは、AuthenticatesUsersに設定されているtraitのThrottlesLoginsに記述されています。


trait AuthenticatesUsers
{
    use RedirectsUsers, ThrottlesLogins;

ThrottlesLogins.phpのhasTooManyLoginAttempsメソッドでは、入力エラーの回数がmaxAttempsの数(デフォルトでは5回)を超えていないかチェックをしています。もし超えた場合は、1分間ログイン処理が行えなくなります。


protected function hasTooManyLoginAttempts(Request $request)
{
    return $this->limiter()->tooManyAttempts(
        $this->throttleKey($request), $this->maxAttempts()
    );
}

入力エラーの回数が設定値を超えた場合は、sendLockoutResponseメソッドが実行されValidationExceptionが投げられログイン画面にメッセージ(Too many logins attemps. Please try again….)が表示されます。


protected function sendLockoutResponse(Request $request)
{
    $seconds = $this->limiter()->availableIn(
        $this->throttleKey($request)
    );

    throw ValidationException::withMessages([
        $this->username() => [Lang::get('auth.throttle', [
            'seconds' => $seconds,
            'minutes' => ceil($seconds / 60),
        ])],
    ])->status(Response::HTTP_TOO_MANY_REQUESTS);
}
ログインが何度も失敗した場合
ログインが何度も失敗した場合

ログインのメイン処理(attemptLogin)

attemptLoginメソッドがログイン処理でのメイン処理の部分です。この処理にたどり着いたということは入力フォームで入力した項目のバリデーションとログインの試行回数をクリアしています。

ここでは入力した値とデータベースに保存されているユーザ情報のチェックを行うので、問題なければ認証が完了しログイン処理は終わります。チェックに失敗した場合は最初に5つのパートに分けた後半の2つの処理を実行します。チェックに成功した場合は5つのパートのうち3つのみ処理が実行されます。


if ($this->attemptLogin($request)) {
    return $this->sendLoginResponse($request);
}

attemptLoginメソッドの中では$this->guard()メソッドでSessinGuardインスタンスが作成され、SessionGuardインスタンスのattemptメソッドが実行されます。


protected function attemptLogin(Request $request)
{
    return $this->guard()->attempt(
        $this->credentials($request), $request->filled('remember')
    );
}
$this->guard()はAuthenticatesUsers.phpファイルのguardメソッドのAuth::guest()でどのガードを利用するかを決めています。引数に何もとらない場合はDefaultで設定したwebが利用され、別のガードを指定すればそのガードを利用して処理を行います。
fukidashi

attemptメソッドに渡される$this->creditianls($request)を確認します。配列としてemailとpasswordを取得しています。


protected function credentials(Request $request)
{
    return $request->only($this->username(), 'password');
}

実行結果の中身は下記のような配列です。


array:2 [▼
  "email" => "johndoe@example.com"
  "password" => "password"
]

$request->filled(‘remember’)はRequest.phpのtraitのIlluminate\Http\Concerns\InteractsWithInputのメソッドでrememberが入っているかチェックを行っています。Remember meをチェックしている場合はtrue、入っていない場合はfalseになります。

今回はremeber meにチェックが入っていない状態で進めます。
fukidashi

SessionGuardインスタンスのattemptメソッド

attemptメソッドにはメールアドレスとパスワードの配列とremember meがチェックされたかどうかの真偽値が渡されることがわかったので、attemptメソッドをみていきましょう。


  public function attempt(array $credentials = [], $remember = false)
  {
    //EVENTを発生させているため、Listenすることが可能
      $this->fireAttemptEvent($credentials, $remember);

      $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

      if ($this->hasValidCredentials($user, $credentials)) {

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

          return true;
      }

      return false;
  }
retriveByCredentialsメソッドによるユーザ情報取得

ようやくここでretriveByCredentialsメソッドを使ってユーザ情報を取得しています。$this->providerはIlluminate\Auth\EloquentUserProviderなので、retrieveByCredentialsメソッドはEloquentUserProvider.phpファイルに記述されています。引数にはメールアドレスとパスワードが入った$credentialsを渡しています。


$this->provider->retrieveByCredentials($credentials);

retrieveByCredentialsメソッドでは、$credentialsが空でないか配列の数が1でないかpasswordのキーを含んでいるかチェックをしています。


public function retrieveByCredentials(array $credentials)
{
    if (empty($credentials) ||
       (count($credentials) === 1 &&
        array_key_exists('password', $credentials))) {
        return;
    }

    $query = $this->newModelQuery();

    foreach ($credentials as $key => $value) {
        if (Str::contains($key, 'password')) {
            continue;
        }

        if (is_array($value) || $value instanceof Arrayable) {
            $query->whereIn($key, $value);
        } else {
            $query->where($key, $value);
        }
    }

    return $query->first();
}

後半の処理ではemailをwhere句に設定してUserモデルからユーザ情報の取得を行っています。ここの処理ではまだpasswordの値に関する処理を行っていません。

hasValidCredentialsメソッド

取得したretrieveByCredentiaslメソッドで取得した$userと$credentials(email,password)を使ってhasValidCredentialsメソッド実行しています。


protected function hasValidCredentials($user, $credentials)
{
    return ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
}

hasValidCredentialsメソッドではEloquentUserProvider.phpファイルのvalidateCredentialsメソッドを実行しています。


protected function hasValidCredentials($user, $credentials)
{
    return ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
}

validateCredentialsメソッドではユーザが入力したパスワードと$userが持っているパスワード(Userモデルを経由してデータベースから取得したユーザ情報)が一致するかチェックを行っています。$userが持っているパスワードは暗号化されているので、Hasherを利用してチェックを行います。checkメソッドは、Illuminate\Hashing\BcrptHasherが持っています。


public function validateCredentials(UserContract $user, array $credentials)
{
    $plain = $credentials['password'];

    return $this->hasher->check($plain, $user->getAuthPassword());
}
$user->getAuthPassword()はpasswordが暗号化された文字列です。
fukidashi

入力したパスワードとデータベースから取得したユーザのパスワードが一致しない場合は、hasValidCredentialsメソッドの結果がfalseになるのでログインに失敗します。一致した場合のみloginメソッドが実行されます。


if ($this->hasValidCredentials($user, $credentials)) {

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

    return true;
}
SessionGuardのloginメソッド

入力したパスワードとデータベースに保存されたユーザのパスワードが一致しただけで終わりではなく次は$userを利用してloginメソッドが実行されます。loginメソッドの中ではSessionの更新も行われます。


public function login(AuthenticatableContract $user, $remember = false)
{

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

    if ($remember) {
        $this->ensureRememberTokenIsSet($user);

        $this->queueRecallerCookie($user);
    }

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

    $this->setUser($user);
}

updateSessionメソッドの中では、最初にSessionにユーザのidを設定しています。$this->sessionはIlluminate\Session\Storeです。Store.phpファイル内にputとmigrateメソッドが記述されています。


protected function updateSession($id)
{
    $this->session->put($this->getName(), $id);

    $this->session->migrate(true);
}

idを設定する項目には、$this->getName()メソッドでユニークな名前をつけるためlogin_web_59ba36addc2b2f9401580f014c7f58ea4e30989dという文字列に設定されます。static::classはIlluminate\Auth\SessionGuardです。sha1でハッシュ化することで59ba36addc2b2f9401580f014c7f58ea4e30989dという文字列に変わります。


public function getName()
{
    return 'login_'.$this->name.'_'.sha1(static::class);
}

$this->session->migrate(true)では$destoryがtrueに設定されているので、handlerを利用してSession情報の入ったファイルを削除しています。デフォルトではセッションハンドラーはIlluminate\Session\FileSessionHandlerです。

セッションハンドラーはSessionをどの方法で管理するかによって変わります。デフォルトではFileにsession情報を保存します。そのほかにdatabaseやcookieやradisなども使用することができます。
fukidashi

public function migrate($destroy = false)
{

    if ($destroy) {
        $this->handler->destroy($this->getId());
    }
    // FileSessionHanderでは影響なし
    $this->setExists(false);

    $this->setId($this->generateSessionId());

    return true;
}

FileSessionHandlerのdestroyメソッドを確認すると指定したセッションIDを持つファイルを削除しています。


public function destroy($sessionId)
{
    $this->files->delete($this->path.'/'.$sessionId);

    return true;
}
Sessionファイルのデフォルトの保存先は、storage/framework/sessionsです。ファイル名はSession IDです。ログインが完了するとログイン前にあったファイルが削除され新しいSession IDを持ったファイルが新しく作成されます。確認した場合はログイン前と後でファイルの変化を見てください。
fukidashi

migrateメソッドの中ではsetIdメソッドを使って新しくSession IDを作成しています。Seesion IDは40桁のランダムの文字列です。


public function setId($id)
{
    $this->id = $this->isValidId($id) ? $id : $this->generateSessionId();
}

protected function generateSessionId()
{
    return Str::random(40);
}

updateSessionメソッドが終わった後、rememberがtrueの場合は以下の2つのメソッドが実行されますが今回rember meは使用していないのでこの処理の説明はスキップします。


if ($remember) {
    $this->ensureRememberTokenIsSet($user);

    $this->queueRecallerCookie($user);
}

loginメソッドでは最後に$this->setUser($user)メソッドを実行しますが取得した$user情報を設定して、loggedOut変数をfalseにしているだけです。


public function setUser(AuthenticatableContract $user)
{
    $this->user = $user;

    $this->loggedOut = false;

    $this->fireAuthenticatedEvent($user);

    return $this;
}

認証が正常に終わった場合は、attemptメソッドの最後にtrueが戻されます。

attmptloginメソッドの処理もtureになるので次はsendLoginResponseメソッドが実行されます。


if ($this->attemptLogin($request)) {
    return $this->sendLoginResponse($request);
}

sendLoginResponseメソッド

sendLoginResponseメソッドではsessionの再作成を行い、ログイン情報を削除して、リダイレクトを行います。


protected function sendLoginResponse(Request $request)
{
    $request->session()->regenerate();

    $this->clearLoginAttempts($request);

    return $this->authenticated($request)
            ?: redirect()->intended($this->redirectPath());
}

$request->session()->regenarete()メソッドではSessionの再作成を行います。先程にも実行したmigarateメソッドも実行されていますが今回は$destrory変数はfalseなのでファイルの削除は行われません。Session IDだけではなくregenerateTokenメソッドでTokenの再作成も行います。


public function regenerate($destroy = false)
{
    return tap($this->migrate($destroy), function () {
        $this->regenerateToken();
    });
}

clearLoginAttemptsメソッドではキャッシュに保存されているログインの失敗回数をクリアしています。


protected function clearLoginAttempts(Request $request)
{
    $this->limiter()->clear($this->throttleKey($request));
}

$this->authenticated($request, $this->guard()->user())は中身が空のメソッドなので、redirect()->intended()が実行されます。


return $this->authenticated($request, $this->guard()->user())
  ?: redirect()->intended($this->redirectPath());

$this->redirectPath()メソッドでは、loginController.phpファイル内にredirectToメソッドと$redirectTo変数があるかチェックを行い、ある場合はそれぞれに設定された値を戻します。デフォルトでは$redirectToに/homeが設定されています。

ログイン後にリダイレクトする場所を変更したい場合はLoginControler.phpの$redirectToに設定できます。
fukidashi

public function redirectPath()
{
    if (method_exists($this, 'redirectTo')) {
        return $this->redirectTo();
    }

    return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home';
}

intentedメソッドの中ではSessionに保存されているurl.intendedがある場合はその値取り出しそのページにリダイレクトします。もしない場合はredirectPath()メソッドで取得したパスへリダイレクトされます。


public function intended($default = '/', $status = 302, $headers = [], $secure = null)
{
    $path = $this->session->pull('url.intended', $default);

    return $this->to($path, $status, $headers, $secure);
}

例えばmiddlewareのauthが設定されいる/postというルーティングがあります。ログアウトした状態で直接/postにアクセスするとログイン画面が表示されます。そのログイン画面からログインが完了した時は/postにリダイレクトされます。これはSessionのurl.intendedに/postが設定されているためです。

ステータスコード302はリダイレクトを意味します。
fukidashi

ここまでが認証が成功した場合に行われる処理です。

ログイン試行回数を増やす

attemptLoginで入力したフォームとデータベースの中に保存されたユーザ情報が一致するかチェックしましたが、一致しない場合はattmptLoginメソッドの結果がfalseとなり、incrementLoginAttemptsメソッドが実行されます。


$this->incrementLoginAttempts($request);

incrementLoginAttempsメソッドはtraitのThrottlesLogins.phpファイルに記述されています。ログインの失敗した回数を保存しています。incrementなので失敗の回数が1つ増えます。


protected function incrementLoginAttempts(Request $request)
{
    $this->limiter()->hit(
        $this->throttleKey($request), $this->decayMinutes() * 60
    );
}

ログイン失敗のレスポンス

sendFailedLoginResponseメソッドでは、ログインの失敗した試行回数を増やした後ログインが失敗したレスポンス処理を行います。


return $this->sendFailedLoginResponse($request);

sendFailedLoginResponseではValidationExceptionが投げられています。入力フォームに表示されるメッセージはresources\lang\en\auth.phpファイルのfailedが設定されます。ユーザのログイン画面にはこのメッセージが表示されます。


protected function sendFailedLoginResponse(Request $request)
{
    throw ValidationException::withMessages([
        $this->username() => [trans('auth.failed')],
    ]);
}

auth.phpファイルには、アプリケーションの各自の要件によって自由に書き換えてもいいよと記述されているので、文言を日本語にすれば日本語でエラーメッセージが表示されます。


return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Language Lines
    |--------------------------------------------------------------------------
    |
    | The following language lines are used during authentication for various
    | messages that we need to display to the user. You are free to modify
    | these language lines according to your application's requirements.
    |
    */

    'failed' => 'These credentials do not match our records.',
    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',

];

ここまでで認証が成功した場合と失敗した場合のログインの一連の流れの説明は完了です。