Laravelアプリケーションにいつどのユーザがログイン、ログアウトをしたか知りたい時にLaravelの認証機能に備わっているイベントを活用することでログイン情報のログを取得することができます。さらにログイン情報のログを取得することで海外から不正なアクセスが継続的に行われいないかやユーザの認証情報が盗まれ不正にログインが行われいるかも確認することができるようになります。

本文書ではLaravel認証機能に備わっているイベントを活用してデータベースにログ情報の保存する方法について説明を行っています。また取得した情報を利用して不自然なアクセスがあった場合のデータの取得方法についても説明しています。Laravel8を利用して動作確認を行っています。

デフォルトではLaravelの認証機能にはログインに複数回失敗すると一定時間ユーザのログイン処理をロックすることで不正なアクセスを防ぐ仕組みが備わっています。

Larvavelの認証機能のイベントを利用してログイン情報を取得する場合はイベントとリスナーを理解しておく必要があります。またユーザへ不自然なアクセス検知を通知したい場合はNotificationの理解も必要となります。

Laravel環境の構築

Laravelのインストールを行って動作確認に必要な環境を構築します。laravelコマンドを利用している場合はnewの値に任意の名前のプロジェクト名をつけて実行してください。


% laravel new login_event 

Laravelの認証機能をインストールするためにLaravel/Breezeのインストールを行います。


 % cd login_event
 % composer require laravel/breeze
 % php artisan breeze:install
Breeze scaffolding installed successfully.
Please execute the "npm install && npm run dev" command to build your assets.
 % npm install && npm run dev

ユーザ情報を保存するためにデータベースが必要となるのでここでは簡易的に利用できるSQLiteデータベースを利用します。

login_eventフォルダの直下でデーベースファイルの作成を行います。


 % touch database/database.sqlite

.envファイルでDB_CONNECTIONの値をmysqlからsqliteに変更を行ってください。そのほかのDBが先頭につく変数は.envファイルから削除してください。

ここまでの設定完了後、php artisan migrateコマンドを実行してSQLiteのデータベースにテーブルの作成を行います。


 % php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (3.62ms)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (2.69ms)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (2.78ms)

php artisan serveコマンドを実行し、http://127.0.0.1:8000/registerにブラウザでアクセスしてユーザ登録画面が表示されることを確認してユーザ登録を行ってください。

Laravel Breeze Register User
Laravel Breeze Register User

ユーザの登録が完了したらこれからログインの監視を行なっていくのでログアウトを行うログイン画面を開いてください。

ユーザ名でログイン
ユーザ名でログイン

ログインに関するイベントをキャッチする

Laravel/Breezeを利用することでユーザの登録、ログイン、ログアウトを行うことができますがデフォルトではどのユーザがログインしたといった情報は残りません。

WEBサーバ側のログを見たらアクセスしたIPアドレスとどのURLにアクセスしたかはわかります。

ログイン情報を残すという設定はありませんがユーザの認証機能の各所にイベントが設定されているのでそのイベントに対するリスナーを設定することでログイン情報を残す仕組みを追加で組み込むことができます。

Lockoutイベントの利用方法

イベントを使ってどのように情報を残すのかということを確認するためにLockoutイベントで動作確認を行います。

Laravel/Breezeの一連のログイン処理が記述されたApp\Http\Requests\Auth\LoginRequest.phpファイルにはLockoutイベントが含まれています。Lockoutイベントは、ログイン画面で5回連続でログインに失敗し、60秒間ログイン処理ができなくなった時に発火されます。

LoginRequest.phpファイルのensureIsNotRateLimitedメソッドの中にLockoutイベントが含まれています。Lockoutイベントのクラスは、Illuminate\Auth\Eventsに保存されています。イベントのデフォルトの保存先のApp\Eventsとは場所が異なることを確認しておく必要があります。


use Illuminate\Auth\Events\Lockout;
//略
public function ensureIsNotRateLimited()
{
    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),
        ]),
    ]);
}

イベント名とイベントクラスの保存先がわかったのでLockoutイベントに対応するとリスナーを作成します。php artisan make:listenerコマンドでリスナーファイルを作成することができ、–eventでリスナーで検知したいイベントであるLockoutイベントを指定しています。リスナーの名前はLockoutToLogにしています。名前は任意です。


 % php artisan make:listener LockoutToLog --event=Lockout
Listener created successfully.

コマンドを実行するとApp\ListenerフォルダにLockoutToLogファイルが作成されます。ファイルを開くと–eventで設定したLockoutクラスが設定された状態で作成されますがimportするクラスの場所がデフォルトのApp\EventではないのでApp\Event\LockoutからIlluminate\Auth\Events\Lockoutに変更する必要があります。


namespace App\Listeners;

use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class LockoutToLog
{
    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  Lockout  $event
     * @return void
     */
    public function handle(Lockout $event)
    {
        //
    }
}

リスナーではイベントをキャッチするとhandleメソッドを実行するのでhandleメソッドで$event変数に保存されているrequestの情報をヘルパー関数のlogger関数を利用してログファイルに情報を書き出します。Laravelのログファイルの保存先はstorage/logs/laravel.log


public function handle(Lockout $event)
{
    logger()->info($event->request->all());
}

最後にEventServiceProvider.phpファイルにLockoutイベントが発火した時に実行されてるリスナーの設定を行います。デフォルトでRegisteredとSendEmailVerificationNotificationのイベントとリスナーのみ設定済です。


namespace App\Providers;

use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use App\Listeners\LockoutToLog;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
        Lockout::class => [
            LockoutToLog::class,
        ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}

設定が完了したのでログイン画面で5回ログイン失敗した後再度ログインを実行するとLockoutイベントが発火しリスナーのLockoutToLogがイベントをキャッチしてログファイルに$request(ユーザがログイン画面で入力した情報を含む)の情報を書き出します。ユーザが入力したパスワードもログファイルの中で確認することができます。


[2021-05-02 06:02:25] local.INFO: array (
  '_token' => 'ybjTGXNK66mBR40IiIxRa9rSMOIoNVZgIPDDOGIZ',
  'email' => 'john@example.com',
  'password' => 'g',
)

イベントを利用した情報の取得方法を理解することができました。

認証に関連するイベントを確認する

認証に関するイベントはvendor/laravel/framework/src/Illuminate/Auth/Eventsフォルダの下に保存されています。先ほど確認したLockout.phpファイルも確認できます。


 % ls
Attempting.php		Lockout.php		PasswordReset.php
Authenticated.php	Login.php		Registered.php
CurrentDeviceLogout.php	Logout.php		Validated.php
Failed.php		OtherDeviceLogout.php	Verified.php

ファイル名を見ただけでもどのような時にイベントが発火するかわかるように命名されています。

本文書ではログイン処理の詳細説明は行いませんが、ログイン処理のコードを追っていくとIlluminate\Auth\SessionGuardというクラスでログイン処理が行われていることがわかります。SessionGuard.phpファイルを見るとファイルの先頭に先ほど確認したイベントのファイルがインポートされていることが確認できます。


namespace Illuminate\Auth;

use Illuminate\Auth\Events\Attempting;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Auth\Events\CurrentDeviceLogout;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Auth\Events\OtherDeviceLogout;
use Illuminate\Auth\Events\Validated;
//略

ログインしたユーザ情報取得

ログファイルへの保存

Loginイベントを利用してログインしたユーザの情報を取得してみましょう。Lockoutイベントと同様にログファイルへの書き出しを行うため手順は先ほどと変わりません。

php artisan make:listenerコマンドでリスナーファイルの作成を行います。


 % php artisan make:listener LoginToLog --event=Login 
Listener created successfully.

App\ListenerフォルダにLoginToLog.phpファイルが作成されるのでLoginイベントのパスの変更を行います。


namespace App\Listeners;

use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class LoginToLog
{
    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  Login  $event
     * @return void
     */
    public function handle(Login $event)
    {
        //
    }
}

handleメソッドの中でログファイルに書き出ししたい情報を記述します。その前にLoginイベントからどのような情報がイベントと一緒に送られてくるか確認をしておきます。constructorメソッドを見ると$user, $guard, $rememberであることがわかります。


public function __construct($guard, $user, $remember)
{
    $this->user = $user;
    $this->guard = $guard;
    $this->remember = $remember;
}

LoginToLog.phpファイルのhandleメソッドは$user, $guard, $remember以外にIPアドレスとアクセスしたブラウザの情報も取得するためUserAgentも取得します。


public function handle(Login $event)
{
    logger()->info($event->user);
    logger()->info($event->guard);
    logger()->info($event->remember);
    logger()->info(request()->ip());
    logger()->info(request()->userAgent());
}

イベントとリスナーが設定できたらEventServiceProvider.phpファイルに登録します。


namespace App\Providers;

use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use App\Listeners\LockoutToLog;
use App\Listeners\LoginToLog;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        Registered::class => [
            SendEmailVerificationNotification::class,
        ],
        Lockout::class => [
            LockoutToLog::class,
        ],
        Login::class => [
            LoginToLog::class,
        ],
    ];

設定が完了できたらログイン画面からログインを実行してください。ログファイルにhandleメソッドで設定した情報が表示されます。


[2021-05-03 01:00:51] local.INFO: {"id":1,"name":"John Doe","email":"john@example.com","email_verified_at":null,"created_at":"2021-05-02T05:33:57.000000Z","updated_at":"2021-05-02T05:33:57.000000Z"}
[2021-05-03 01:00:51] local.INFO: web
[2021-05-03 01:00:51] local.INFO:
[2021-05-03 01:00:51] local.INFO: 127.0.0.1
[2021-05-03 01:00:51] local.INFO: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36

先ほどはChromeでログインを行なっていましたがSafariを利用してログインを行うとuserAgentの情報が変わるのも確認できます。


[2021-05-03 01:09:15] local.INFO: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15
ログイン画面でRemember meにチェックを入れてログインを行うとログには1が表示されます。チェックしない場合は何も表示されません。

データベースへの保存

ログイン情報をログファイルではなくデータベースに保存することもできます。データベースへの保存方法について説明を行います。

php artisam make:modelコマンドを使ってデータベースのマイグレーションファイルの作成とモデルファイルの作成を行います。モデル名はAuthHistoryとしています。


 % php artisan make:model AuthHistory -m 
Model created successfully.
Created Migration: 2021_05_03_013032_create_auth_histories_table

app\Modelsフォルダの下にAuthHistory.phpファイルとdatabase\migrationsフォルダの下にマイグレーションファイルが作成されます。

ログイン情報のどの情報をデータベースのテーブルに保存するのかを決める必要がありますが本文書ではuser_id, ip_address, user_agent, login_timeの情報を保存します。必要な情報の列を追加してください。


class CreateAuthHistoriesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('auth_histories', function (Blueprint $table) {
            $table->id();
            $table->string('user_id')->nullable();
            $table->string('ip_address')->nullable();
            $table->text('user_agent')->nullable();
            $table->timestamp('login_time')->nullable();
            $table->timestamps();
        });
    }

AuthHistory.phpファイルに列情報を追加しモデルを経由してデータが保存できるように設定します。


namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class AuthHistory extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id','ip_address','user_agent','login_time'
    ];
    
}

テーブルの設定が完了したら、php artisan migrateコマンドを実行します。データベースにはauth_historiesテーブルが作成されます。


 % php artisan migrate
Migrating: 2021_05_03_013032_create_auth_histories_table
Migrated:  2021_05_03_013032_create_auth_histories_table (3.68ms)

LoginToLogファイルのhandleメソッドをログファイルの書き込みからデータベースへの書き込みに変更します。


public function handle(Login $event)
{
    // logger()->info($event->user);
    // logger()->info($event->guard);
    // logger()->info($event->remember);
    // logger()->info(request()->ip());
    // logger()->info(request()->userAgent());
    AuthHistory::create(
        [
            'user_id' => $event->user->id,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'login_time' => \Carbon\Carbon::now()
        ]
    );
}

ブラウザからログインを実行してログイン情報がデータベースに書き込まれるか確認します。TablePlusというデータベース管理GUIツールを利用していますが、auth_historiesテーブルの中身を確認するとログイン情報が書き込まれていることが確認できます。

ログイン情報を確認
ログイン情報を確認

これだけの処理でログイン情報をデータベースに取得する仕組みを追加することができました。

テーブルに保存された情報を活用

最近のクラウドベースのサービスの場合と不正なアクセスでログインされていないか確認を行うためにこれまでにアクセスの行われたIPアドレスとUserAgent以外でログインするとメールアドレスに通知が送信される仕組みがあります。

今回作成したauth_historiesテーブルにはIPアドレスとUser Agentも一緒に保存しているのでIPアドレスとUser Agentの過去の履歴を確認することで通知を行う仕組みを実装することもできます。

handleメソッドの中で過去の履歴を確認しもし履歴に存在しない場合はログファイルにその情報を書き込みます。


public function handle(Login $event)
{
    $new_acceess = AuthHistory::where('ip_address',request()->ip())
                   ->where('user_agent',request()->userAgent())
                   ->first();

    AuthHistory::create(
        [
            'user_id' => $event->user->id,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'login_time' => \Carbon\Carbon::now()
        ]
    );

    if(is_null($new_acceess)){
        logger()->info('これまでにはないIPアドレス'.request()->ip().'とブラウザ'.request()->userAgent().'からのアクセスです。');
    }
}

これまでにアクセスしたブラウザとは異なるブラウザでアクセスを行うとログファイルに情報が書き込まれます。米初めてのログインでも以前の履歴がないため情報が書き込まれます。


[2021-05-03 02:05:39] local.INFO: これまでにはないIPアドレス127.0.0.1とブラウザMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36からのアクセスです。

ログへの書き込みではなくNotificationの処理を追加することで通知を行うことも可能です。ここではNotificationの設定は行っていませんが下記のように通知の仕組みを追加することができます。


public function handle(Login $event)
{
    $new_acceess = AuthHistory::where('ip_address',request()->ip())
                        ->where('user_agent',request()->userAgent())->first();

    $access_info = new AuthHistory(            [
            'user_id' => $event->user->id,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'login_time' => \Carbon\Carbon::now()
        ]);

    $access_info->save();

    if(is_null($new_acceess)){
        $user->notify(new NewAccessNotification($access_info));
    }
    
}

本文書ではLockout, Loginの2つのイベントのみを利用して動作確認を行いましたが、Logoutイベントを利用することでログアウトしたユーザの情報を取得することができます。ログインに失敗した場合はFailedイベント、ログインの成功、失敗に関わらずログイン情報を取得したい場合はAttemptingイベントを利用することができます。