🐘

Laravel Breezeのソースを読んだので超絶詳しく解説していく【ログイン処理編】

2023/04/17に公開

はじめに

みなさん、きちんとフレームワークやライブラリのコードを読んでいますか?

自分はこれまで「ここってどうなってるんだろう?」と思いながらも、「とりあえず使えないことには始まらない」「読解する時間がない」「基本がわからないとどうしようもない」という理由でフレームワークのコードを読むことを後回しにしてきました。

けど、それを後回しにしてもいい時間はもう終わり。

今のレベルから一つ上のレベルに上がっていくためには「なぜ?」を放置せず、疑問を突き詰め、さらなる疑問にぶちあたり、またその疑問を調べ解決していくという作業が必要なのではないかと思います。

そこで以前から利用しているもののその中身を詳しく覗いたことがなかった、"Laravel Breeze"のソースコードを読み解いていこうかと思います。

シリーズものにしていこうかと考えており、今回はまず基本も基本、ログイン処理から見ていこうと思います。

Laravel Breeze とは?

Laravel Breeze は、簡単に認証機能を追加することができる Laravel のパッケージです。

Breeze は Laravel 8.x のバージョンから導入され、Jetstream のような高度な機能を提供しない代わりに、より簡素なインストールと認証プロセスを提供しています。

1. login View

まずは login.blade.php を確認し、ログインボタンを押下した際にどのルートを通っているのかを確認します。

login.blade.php
<x-guest-layout>
    <!-- Session Status -->
    <x-auth-session-status class="mb-4" :status="session('status')" />

    <form method="POST" action="{{ route('login') }}">
        @csrf

        <!-- Email Address -->
        <div>
            <x-input-label for="email" :value="__('Email')" />
            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
            <x-input-error :messages="$errors->get('email')" class="mt-2" />
        </div>

        <!-- Password -->
        <div class="mt-4">
            <x-input-label for="password" :value="__('Password')" />

            <x-text-input id="password" class="block mt-1 w-full"
                            type="password"
                            name="password"
                            required autocomplete="current-password" />

            <x-input-error :messages="$errors->get('password')" class="mt-2" />
        </div>

        <!-- Remember Me -->
        <div class="block mt-4">
            <label for="remember_me" class="inline-flex items-center">
                <input id="remember_me" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember">
                <span class="ml-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
            </label>
        </div>

        <div class="flex items-center justify-end mt-4">
            @if (Route::has('password.request'))
                <a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}">
                    {{ __('Forgot your password?') }}
                </a>
            @endif

            <x-primary-button class="ml-3">
                {{ __('Log in') }}
            </x-primary-button>
        </div>
    </form>
</x-guest-layout>

<form method="POST" action="{{ route('login') }}">より、POSTでloginというルートを通ることがわかります。

2. routes/auth.php

では、auth.php を確認します。ファイル全体ではなく、一部を抜粋します。

routes/auth.php
Route::middleware('guest')->group(function () {
    Route::get('register', [RegisteredUserController::class, 'create'])
                ->name('register');

    Route::post('register', [RegisteredUserController::class, 'store']);

    Route::get('login', [AuthenticatedSessionController::class, 'create'])
                ->name('login');

    Route::post('login', [AuthenticatedSessionController::class, 'store']);

    Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
                ->name('password.request');

    Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
                ->name('password.email');

    Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
                ->name('password.reset');

    Route::post('reset-password', [NewPasswordController::class, 'store'])
                ->name('password.store');
});

POST で login というルートを通る際は、AuthenticatedSessionController の store メソッドが呼ばれるようです。

3. AuthenticatedSessionController.php

AuthenticatedSessionController.php の store メソッドの抜粋を記載します。

app/Http/Controllers/Auth/AuthenticatedSessionController.php
/**
 * Handle an incoming authentication request.
 */
public function store(LoginRequest $request): RedirectResponse
{
    $request->authenticate();

    $request->session()->regenerate();

    return redirect()->intended(RouteServiceProvider::HOME);
}

ログインコントローラー自体はものすごくシンプルに作られていますね。ファットコントローラーにならないよう、適切に処理を切り出しています。

ざっとこの 3 行の処理でやっていることを要約すると、

  1. 認証
  2. セッションを再生成
  3. ログイン後の画面へリダイレクト

をやっているみたいです。

2,3 の処理については要約そのままのことなので特に解説はしないのですが、1 の$request->authenticate()メソッドの中身を覗いてみて、何をしているのか詳しく見ていきます。

4. LoginRequest.php

それでは、$request の実体である LoginRequest.php の authenticate メソッドを確認します。

app/Http/Requests/Auth/LoginRequest.php
/**
 * Attempt to authenticate the request's credentials.
 *
 * @throws \Illuminate\Validation\ValidationException
 */
public function authenticate(): void
{
    $this->ensureIsNotRateLimited();

    if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
        RateLimiter::hit($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.failed'),
        ]);
    }

    RateLimiter::clear($this->throttleKey());
}

上から順に確認していきます。

5. 同 LoginRequest.php の ensureIsNotRateLimited メソッド

app/Http/Requests/Auth/LoginRequest.php
/**
 * Ensure the login request is not rate limited.
 *
 * @throws \Illuminate\Validation\ValidationException
 */
public function ensureIsNotRateLimited(): void
{
    if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
        return;
    }

    event(new Lockout($this));

    $seconds = RateLimiter::availableIn($this->throttleKey());

    throw ValidationException::withMessages([
        'email' => trans('auth.throttle', [
            'seconds' => $seconds,
            'minutes' => ceil($seconds / 60),
        ]),
    ]);
}

まず$this->throttleKey()ですが、中身を確認します。

app/Http/Requests/Auth/LoginRequest.php
/**
 * Get the rate limiting throttle key for the request.
 */
public function throttleKey(): string
{
    return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
}

どうやらこのメソッドは、メールアドレスと IP アドレスをパイプで連結した文字列を返し、メールアドレスは小文字で統一されるようです。

どんどんネストが深くなっていってしまうので、Str::transliterate のコードまでは載せないことにするのですが、中を見てみるとメールアドレスと IP アドレスをパイプで連結した文字列のうち、ASCII 文字のみを返しているようです。

この時点ではアクセス元の誰かは認証されておらず、Laravel が認証できるユーザーですらないため、アクセス元の識別のために入力されたメールアドレスとアクセス元の IP で"誰か"を認識しているみたいですね。

話題を ensureIsNotRateLimited メソッド本体に戻して、ざっくり何をしてるか確認すると、

  1. とあるユーザーが 5 回程度(以上か超過かは中を見ないとわからない)ログイン試行していない場合、return。そうでない場合、Lockout というイベントを発火させる
  2. RateLimiter::availableIn($this->throttleKey())より、何かしらの秒数を取得する
  3. 例外を吐き出す

以上をやっているみたいです。

ではこの 3 つの処理を順に見ていきましょう。

6. RateLimiter.php の tooManyAttempts メソッド

名前的に試行回数が一定回数を超えているか?を判定してそうですが、中をみて裏をとっていきます。

vendor/laravel/framework/src/Illuminate/Cache/RateLimiter.php
/**
 * Determine if the given key has been "accessed" too many times.
 *
 * @param  string  $key
 * @param  int  $maxAttempts
 * @return bool
 */
public function tooManyAttempts($key, $maxAttempts)
{
    if ($this->attempts($key) >= $maxAttempts) {
        if ($this->cache->has($this->cleanRateLimiterKey($key).':timer')) {
            return true;
        }

        $this->resetAttempts($key);
    }

    return false;
}

/**
 * Get the number of attempts for the given key.
 *
 * @param  string  $key
 * @return mixed
 */
public function attempts($key)
{
    $key = $this->cleanRateLimiterKey($key);

    return $this->cache->get($key, 0);
}

/**
 * Reset the number of attempts for the given key.
 *
 * @param  string  $key
 * @return mixed
 */
public function resetAttempts($key)
{
    $key = $this->cleanRateLimiterKey($key);

    return $this->cache->forget($key);
}

/**
 * Clean the rate limiter key from unicode characters.
 *
 * @param  string  $key
 * @return string
 */
public function cleanRateLimiterKey($key)
{
    return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key));
}

みなさんの脳内メモリを補助すると、$key は$this->throttleKey()で簡単にいうと「メールアドレス|IP」=アクセス元です。$maxAttempts は 5 ですね。

$this->attempts($key)で取得した値が 5 以上の場合、さらに何かしらの条件に該当した場合 true を戻し、そうでなければ、$this->resetAttempts($key)して false を戻しているようです。

おそらくですが、処理的に試行回数をリセットしてそうです。

ということで同じクラスに定義された attempts($key)の中を見ます。

同じクラスの cleanRateLimiterKey に$key を渡して何か処理を施しています。

この処理の意味がよくわからないのですが、なにやら HTML エンティティから実際の文字に戻すために使用される正規表現の操作をしているみたいです。

その結果取得したキーを使って、キャッシュから値を取得しているみたいです。

一応自分の環境での$this->cache->get($key, 0)で呼ばれているクラスとメソッドを載せておきますが、単にキーを使ってキャッシュからログイン試行回数を取得しているみたいです。

vendor/laravel/framework/src/Illuminate/Cache/Repository.php
/**
 * Retrieve an item from the cache by key.
 *
 * @template TCacheValue
 *
 * @param  array|string  $key
 * @param  TCacheValue|(\Closure(): TCacheValue)  $default
 * @return (TCacheValue is null ? mixed : TCacheValue)
 */
public function get($key, $default = null): mixed
{
    if (is_array($key)) {
        return $this->many($key);
    }

    $value = $this->store->get($this->itemKey($key));

    // If we could not find the cache value, we will fire the missed event and get
    // the default value for this cache value. This default could be a callback
    // so we will execute the value function which will resolve it if needed.
    if (is_null($value)) {
        $this->event(new CacheMissed($key));

        $value = value($default);
    } else {
        $this->event(new CacheHit($key, $value));
    }

    return $value;
}

これでキャッシュに保存された現在のログイン回数が 5 回以上かつ、$this->cleanRateLimiterKey($key).':timer'がキャッシュに保持されている場合、tooManyAttempts メソッドは true を返すらしいことがわかりました。

resetAttempts はやはり最初の過程通りで、キャッシュに保存されたログイン試行回数を削除しているようです。

そして、キャッシュに保存された現在のログイン回数が 5 回以上かつ、$this->cleanRateLimiterKey($key).':timer'がキャッシュに保持されている場合に該当する場合は、LoginRequest.php の ensureIsNotRateLimited メソッドに戻った際に、後続の LockOut イベントの発火と例外送出をしているようです。

この後続の例外送出の部分について見ておきましょう。

7. 同 RateLimiter.php の availableIn メソッド

/**
 * Get the number of seconds until the "key" is accessible again.
 *
 * @param  string  $key
 * @return int
 */
public function availableIn($key)
{
    $key = $this->cleanRateLimiterKey($key);

    return max(0, $this->cache->get($key.':timer') - $this->currentTime());
}

キャッシュに保存された値から現在の時刻を引いているので、おそらくキャッシュに保存されているのは再度ログイン試行が可能になる時間だと予測できます。

この計算結果が負数になる場合は 0 を返すので、キャッシュの値が過去時間の場合 0 が戻ることになります。

ただし、0 が戻るのは割と奇跡的なタイミングを想定していることがこのあとの処理をみるとわかります。

再び LoginRequest.php に戻り、throw ValidationException::withMessages の中身を見ます。

vendor/laravel/framework/src/Illuminate/Validation/ValidationException.php
/**
 * Create a new validation exception from a plain array of messages.
 *
 * @param  array  $messages
 * @return static
 */
public static function withMessages(array $messages)
{
    return new static(tap(ValidatorFacade::make([], []), function ($validator) use ($messages) {
        foreach ($messages as $key => $value) {
            foreach (Arr::wrap($value) as $message) {
                $validator->errors()->add($key, $message);
            }
        }
    }));
}

バリデーションエラーメッセージを生成していることが分かります。
ではメッセージの実体を確認します。

trans('auth.throttle', [
                'seconds' => $seconds,
                'minutes' => ceil($seconds / 60),
            ]),

より、vendor/laravel/framework/src/Illuminate/Translation/lang/en/auth.php に記載されているであろう連想配列の throttle の値を見ます。

vendor/laravel/framework/src/Illuminate/Translation/lang/en/auth.php
<?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.',
    'password' => 'The provided password is incorrect.',
    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',

];

minutes も渡しているようですが、どうやら使われておらず秒数表示で"Too many login attempts. Please try again in :seconds seconds."というエラーメッセージが表示されるであろうことがわかりました。

以上が LoginRequest.php の ensureIsNotRateLimited メソッドで行われている処理でした。

8. Auth/SessionGuard.php から呼ばれる attempt メソッド

すでにあちこちへ飛んでいってなかなかしんどい感じですが、次がいよいよ本命。
LoginRequest.php の Auth::attempt で行われている処理について見ていきます。

app/Http/Requests/Auth/LoginRequest.php
/**
 * Attempt to authenticate the request's credentials.
 *
 * @throws \Illuminate\Validation\ValidationException
 */
public function authenticate(): void
{
    $this->ensureIsNotRateLimited();

    if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
        RateLimiter::hit($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.failed'),
        ]);
    }

    RateLimiter::clear($this->throttleKey());
}

Auth::attempt 自体は Facade で、今の初期設定だと、vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php の attempt メソッドが呼ばれます。

vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
/**
 * Attempt to authenticate a user using the given credentials.
 *
 * @param  array  $credentials
 * @param  bool  $remember
 * @return bool
 */
public function attempt(array $credentials = [], $remember = false)
{
    $this->fireAttemptEvent($credentials, $remember);

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

    // If an implementation of UserInterface was returned, we'll ask the provider
    // to validate the user against the given credentials, and if they are in
    // fact valid we'll log the users into the application and return true.
    if ($this->hasValidCredentials($user, $credentials)) {
        $this->login($user, $remember);

        return true;
    }

    // If the authentication attempt fails we will fire an event so that the user
    // may be notified of any suspicious attempts to access their account from
    // an unrecognized user. A developer may listen to this event as needed.
    $this->fireFailedEvent($user, $credentials);

    return false;
}

ここで fire*Event の処理については一旦無視します。

というのは、Event 発火に合わせて何かしらの処理を実行できるようにしてくれているみたいなのですが、現状何もイベント発火に合わせて実行する処理は定義していないからです。

例えば$this->fireAttemptEvent の中身は ↓ のような感じで、Attempting に合わせた処理を発火させられるようになっていますが、初期状態では何も処理は定義されていません。

vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
/**
 * Fire the attempt event with the arguments.
 *
 * @param  array  $credentials
 * @param  bool  $remember
 * @return void
 */
protected function fireAttemptEvent(array $credentials, $remember = false)
{
    $this->events?->dispatch(new Attempting($this->name, $credentials, $remember));
}

というわけでまず見ていくのは、$this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);です。

この provider は config/auth.php に定義された provide によって変わります。

初期設定からなにも変えていない場合、以下のようになっているはず。

config/auth.php
    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication drivers have a user provider. This defines how the
    | users are actually retrieved out of your database or other storage
    | mechanisms used by this application to persist your user's data.
    |
    | If you have multiple user tables or models you may configure multiple
    | sources which represent each model / table. These sources may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

driver に設定されているのが eloquent なので、呼ばれる実態は vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php の retrieveByCredentials メソッドとなります。

vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php
/**
 * Retrieve a user by the given credentials.
 *
 * @param  array  $credentials
 * @return \Illuminate\Contracts\Auth\Authenticatable|null
 */
public function retrieveByCredentials(array $credentials)
{
    $credentials = array_filter(
        $credentials,
        fn ($key) => ! str_contains($key, 'password'),
        ARRAY_FILTER_USE_KEY
    );

    if (empty($credentials)) {
        return;
    }

    // First we will add each credential element to the query as a where clause.
    // Then we can execute the query and, if we found a user, return it in a
    // Eloquent User "model" that will be utilized by the Guard instances.
    $query = $this->newModelQuery();

    foreach ($credentials as $key => $value) {
        if (is_array($value) || $value instanceof Arrayable) {
            $query->whereIn($key, $value);
        } elseif ($value instanceof Closure) {
            $value($query);
        } else {
            $query->where($key, $value);
        }
    }

    return $query->first();
}

/**
 * Get a new query builder for the model instance.
 *
 * @param  \Illuminate\Database\Eloquent\Model|null  $model
 * @return \Illuminate\Database\Eloquent\Builder
 */
protected function newModelQuery($model = null)
{
    $query = is_null($model)
            ? $this->createModel()->newQuery()
            : $model->newQuery();

    with($query, $this->queryCallback);

    return $query;
}

/**
 * Create a new instance of the model.
 *
 * @return \Illuminate\Database\Eloquent\Model
 */
public function createModel()
{
    $class = '\\'.ltrim($this->model, '\\');

    return new $class;
}

だいぶなにをしているのかはっきりしてきた感じがしますね。

retrieveByCredentials(array $credentials)の$credentials に入っている値は、email,password です。

で、retrieveByCredentials(array $credentials)の中でどういう処理が走っているか?自体はすごくシンプルで、まず、$credentials から password 以外の値のみを抽出し、その結果$credentials が空になれば return。

面白いのが次で、$this->newModelQuery();なるメソッドを読んでいます。

このメソッドですが、引数の$model が省略されているので$model は null で始まります。

そうすると is_null($model)は true となり、$query の値は$this->createModel()->newQuery()に決まるわけですがここが面白いところです。

createModel()は$thie->model を返していますが、その中身は config/auth.php で定義していたApp\Models\User::classになります。

その後、ヘルパー関数に定義された with が呼ばれます。

vendor/laravel/framework/src/Illuminate/Support/helpers.php
if (! function_exists('with')) {
    /**
     * Return the given value, optionally passed through the given callback.
     *
     * @template TValue
     * @template TReturn
     *
     * @param  TValue  $value
     * @param  (callable(TValue): (TReturn))|null  $callback
     * @return ($callback is null ? TValue : TReturn)
     */
    function with($value, callable $callback = null)
    {
        return is_null($callback) ? $value : $callback($value);
    }
}

しかし、初期設定からいじっていなければ$callback は null となり、$this->newModelQuery();の戻り値はインスタンス化された App\Models\User::class です。

その後 foreach を回っていくのですが、$credentials は最初の filter の結果 email しか入ってないので、foreach は 1 度だけ周り、最後の else を通ります。

そして、$query->first()するので、この時点ではパスワードの検証はしておらず、email に該当するレコードの 1 行目を取得しているに過ぎないようです。

これで$user には DB から取得した(もっといえば uses テーブルから取得した)レコードが代入され、$this->hasValidCredentials($user, $credentials)が実行されます。

vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
/**
 * Attempt to authenticate a user using the given credentials.
 *
 * @param  array  $credentials
 * @param  bool  $remember
 * @return bool
 */
public function attempt(array $credentials = [], $remember = false)
{
    $this->fireAttemptEvent($credentials, $remember);

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

    // If an implementation of UserInterface was returned, we'll ask the provider
    // to validate the user against the given credentials, and if they are in
    // fact valid we'll log the users into the application and return true.
    if ($this->hasValidCredentials($user, $credentials)) {
        $this->login($user, $remember);

        return true;
    }

    // If the authentication attempt fails we will fire an event so that the user
    // may be notified of any suspicious attempts to access their account from
    // an unrecognized user. A developer may listen to this event as needed.
    $this->fireFailedEvent($user, $credentials);

    return false;
}

/**
 * Determine if the user matches the credentials.
 *
 * @param  mixed  $user
 * @param  array  $credentials
 * @return bool
 */
protected function hasValidCredentials($user, $credentials)
{
    return $this->timebox->call(function ($timebox) use ($user, $credentials) {
        $validated = !is_null($user) && $this->provider->validateCredentials($user, $credentials);

        if ($validated) {
            $timebox->returnEarly();

            $this->fireValidatedEvent($user);
        }

        return $validated;
    }, 200 * 1000);
}

このメソッドでは、タイムボックスと呼ばれる機能が使われています。

タイムボックスは、指定された時間内に結果を返さなかった場合にタイムアウトするように、コードの特定の部分を制限する機能です。

この場合、200 秒の時間制限が設定されています。

このメソッドでは、まず $this->provider->validateCredentials() を使用して、ユーザーの認証情報が有効かどうかをチェックしています。

その後、認証情報が有効である場合は、ユーザーが認証されたことを示す fireValidatedEvent メソッドを呼び出します。

$this->provider->validateCredentials()の provider は先ほどと同じく vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php の validateCredentials()です。

vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php
/**
* Validate a user against the given credentials.
*
* @param  \Illuminate\Contracts\Auth\Authenticatable  $user
* @param  array  $credentials
* @return bool
*/
public function validateCredentials(UserContract $user, array $credentials)
{
    if (is_null($plain = $credentials['password'])) {
        return false;
    }

    return $this->hasher->check($plain, $user->getAuthPassword());
}

ここで、$credentials['password']は画面から入力されたパスワードなので平文で値が入っています。

そして、入力されたパスワードを is_null で確認し、未入力の場合は false を返しているようです。

そうでなければ、$this->hasher->check()で画面から入力されたパスワードと、先ほど DB から取得したパスワード(こっちはハッシュ化されている)の値を比較した結果を返すようです。

$this->hasher->check()の詳細は割愛しているのですが、users テーブルに保存するパスワードのハッシュ化アルゴリズムを変更できるため、その設定に合わせたハッシュ操作クラスの check 処理を読んでいます。

初期設定から変更していない場合は、bcryptが選択されているはずです。

そしてこの比較の結果、画面から入力された ID と DB から取得したパスワードが合致すれば true が返えるので、Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))は true となります。

すると、RateLimiter::clear($this->throttleKey());が実行され、ログイン試行回数がリセットされ authenticate()メソッドの処理が終了します。

逆にパスワードが合致しなかった場合 false が返えるので、RateLimiter::hit($this->throttleKey());が実行され、バリデーションメッセージが表示されることが分かります。

バリデーションメッセージは、vendor/laravel/framework/src/Illuminate/Translation/lang/en/auth.php より、These credentials do not match our recordsであろうことが分かります。

app/Http/Requests/Auth/LoginRequest.php
/**
 * Attempt to authenticate the request's credentials.
 *
 * @throws \Illuminate\Validation\ValidationException
 */
public function authenticate(): void
{
    $this->ensureIsNotRateLimited();

    if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
        RateLimiter::hit($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.failed'),
        ]);
    }

    RateLimiter::clear($this->throttleKey());
}
vendor/laravel/framework/src/Illuminate/Translation/lang/en/auth.php
<?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.',
    'password' => 'The provided password is incorrect.',
    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',

];

またパスワード認証失敗時に実行される RateLimiter::hit($this->throttleKey());を見てみると、先ほど出てきたログイン試行回数や再ログインまでの時間をセットしていることがわかって面白いです。

パスワード認証失敗時には、ログイン試行回数を+1 したのち、5 回連続でログイン処理に失敗すると 1 分待たないとログイン処理ができないよう設定しているのがここだというのが分かります。

また、おそらくタイポがあって、availableAt メソッドをみると$decaySeconds ではなく$delaySeconds が正しいのではないかと思われます。

vendor/laravel/framework/src/Illuminate/Cache/RateLimiter.php
/**
 * Increment the counter for a given key for a given decay time.
 *
 * @param  string  $key
 * @param  int  $decaySeconds
 * @return int
 */
public function hit($key, $decaySeconds = 60)
{
    $key = $this->cleanRateLimiterKey($key);

    $this->cache->add(
        $key.':timer', $this->availableAt($decaySeconds), $decaySeconds
    );

    $added = $this->cache->add($key, 0, $decaySeconds);

    $hits = (int) $this->cache->increment($key);

    if (! $added && $hits == 1) {
        $this->cache->put($key, 1, $decaySeconds);
    }

    return $hits;
}

/**
 * Get the "available at" UNIX timestamp.
 *
 * @param  \DateTimeInterface|\DateInterval|int  $delay
 * @return int
 */
protected function availableAt($delay = 0)
{
    $delay = $this->parseDateInterval($delay);

    return $delay instanceof DateTimeInterface
                        ? $delay->getTimestamp()
                        : Carbon::now()->addRealSeconds($delay)->getTimestamp();
}

また、$this->hasValidCredentials($user, $credentials)で画面から入力された ID と DB から取得したパスワードが一致するのを確認できたら、$this->login($user, $remember);という処理が実行されていることが分かります。

次に見ていくのはこの$this->login($user, $remember);なのですが、引数に$remember という変数をとっています。

これはログイン画面に表示されている Remenber me の値です。

↑ の画像の赤枠部分がそれにあたります。

$this->login($user, $remember);でやっていることは大きく二つで、

  1. セッション情報を更新する
  2. ユーザーのログイン情報を cookie に保存する

です。

1.については updateSession()で完結しているのでよさそうなのですが、2.についてはいろいろやっているようです。

vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
/**
 * Attempt to authenticate a user using the given credentials.
 *
 * @param  array  $credentials
 * @param  bool  $remember
 * @return bool
 */
public function attempt(array $credentials = [], $remember = false)
{
    $this->fireAttemptEvent($credentials, $remember);

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

    // If an implementation of UserInterface was returned, we'll ask the provider
    // to validate the user against the given credentials, and if they are in
    // fact valid we'll log the users into the application and return true.
    if ($this->hasValidCredentials($user, $credentials)) {
        $this->login($user, $remember);

        return true;
    }

    // If the authentication attempt fails we will fire an event so that the user
    // may be notified of any suspicious attempts to access their account from
    // an unrecognized user. A developer may listen to this event as needed.
    $this->fireFailedEvent($user, $credentials);

    return false;
}

/**
 * Log a user into the application.
 *
 * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 * @param  bool  $remember
 * @return void
 */
public function login(AuthenticatableContract $user, $remember = false)
{
    $this->updateSession($user->getAuthIdentifier());

    // If the user should be permanently "remembered" by the application we will
    // queue a permanent cookie that contains the encrypted copy of the user
    // identifier. We will then decrypt this later to retrieve the users.
    if ($remember) {
        $this->ensureRememberTokenIsSet($user);

        $this->queueRecallerCookie($user);
    }

    // If we have an event dispatcher instance set we will fire an event so that
    // any listeners will hook into the authentication events and run actions
    // based on the login and logout events fired from the guard instances.
    $this->fireLoginEvent($user, $remember);

    $this->setUser($user);
}

/**
 * Update the session with the given ID.
 *
 * @param  string  $id
 * @return void
 */
protected function updateSession($id)
{
    $this->session->put($this->getName(), $id);

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

/**
 * Create a new "remember me" token for the user if one doesn't already exist.
 *
 * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 * @return void
 */
protected function ensureRememberTokenIsSet(AuthenticatableContract $user)
{
    if (empty($user->getRememberToken())) {
        $this->cycleRememberToken($user);
    }
}

/**
 * Refresh the "remember me" token for the user.
 *
 * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 * @return void
 */
protected function cycleRememberToken(AuthenticatableContract $user)
{
    $user->setRememberToken($token = Str::random(60));

    $this->provider->updateRememberToken($user, $token);
}

/**
 * Queue the recaller cookie into the cookie jar.
 *
 * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 * @return void
 */
protected function queueRecallerCookie(AuthenticatableContract $user)
{
    $this->getCookieJar()->queue($this->createRecaller(
        $user->getAuthIdentifier() . '|' . $user->getRememberToken() . '|' . $user->getAuthPassword()
    ));
}

vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php
/**
 * Update the "remember me" token for the given user in storage.
 *
 * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 * @param  string  $token
 * @return void
 */
public function updateRememberToken(UserContract $user, $token)
{
    $user->setRememberToken($token);

    $timestamps = $user->timestamps;

    $user->timestamps = false;

    $user->save();

    $user->timestamps = $timestamps;
}

$this->ensureRememberTokenIsSet($user);と$this->queueRecallerCookie($user);が cookie 周りでやっていることのようです。

まず、$this->ensureRememberTokenIsSet($user)と cycleRememberToken メソッドでは、トークンを生成し uses テーブルの remember_token カラムに保存しているようです。

次に$this->queueRecallerCookie($user);では cookie にユーザー ID、パスワード、そして先の ensureRememberTokenIsSet メソッドで生成したトークンを保存しているみたいです。

では、そもそもなぜここで cookie に保存しているのか?という話

ここで cookie にユーザーの情報を保存しておくことで、ユーザーが再びサイトにアクセスすると、クッキーからユーザー ID とリメンバートークンが復号化され、データベースと照合され自動的にログインできるようです。

なぜそんなことができるのかというと、middleware で画面遷移のたびに vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php の user()メソッドが呼ばれており、この中の$this->user = $this->userFromRecaller($recaller);を cookie に保存した情報で実行できているっぽいからなのですが、この辺りはまだ詳しく見れていないので次回のネタにしたいと思います。

vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
/**
 * Get the currently authenticated user.
 *
 * @return \Illuminate\Contracts\Auth\Authenticatable|null
 */
public function user()
{
    if ($this->loggedOut) {
        return;
    }

    // If we've already retrieved the user for the current request we can just
    // return it back immediately. We do not want to fetch the user data on
    // every call to this method because that would be tremendously slow.
    if (!is_null($this->user)) {
        return $this->user;
    }

    $id = $this->session->get($this->getName());

    // First we will try to load the user using the identifier in the session if
    // one exists. Otherwise we will check for a "remember me" cookie in this
    // request, and if one exists, attempt to retrieve the user using that.
    if (!is_null($id) && $this->user = $this->provider->retrieveById($id)) {
        $this->fireAuthenticatedEvent($this->user);
    }

    // If the user is null, but we decrypt a "recaller" cookie we can attempt to
    // pull the user data on that cookie which serves as a remember cookie on
    // the application. Once we have a user we can return it to the caller.
    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;
}

おわりに

今回 Laravel Breeze のログイン処理を詳しく見てみることで、かなりたくさんのことを考えながらコードを書いていることを読み取ることができました。

また、こういうコードをじっくり読んで技術記事にして世に出すということは、仕事でコードを読むのと同じかそれ以上に集中して正しく読み取るいい練習になると思いました。

これからもフレームワークの思想を理解したり、自分のコードの読解力を上げていくためにもこういった学習方法を継続していきたいです。

📢 Kobe.tsというTypeScriptコミュニティを主催しています

フロント・バックエンドに限らず、周辺知識も含めてTypeScriptの勉強会を主催しています。

毎朝オフラインでもくもくしたり、神戸を中心に関西でLTもしています。

盛り上がってる感を出していきたいので、良ければメンバーにだけでもなってください😣

https://kobets.connpass.com/

Discussion