Laravelで同時ログイン数を制御をする

web系の方だと馴染みないかもしれませんが、SIerだと `同時ログイン数を制御したい` という要望って多少はあるのかなと思います。

今日、`Laravelで同時ログイン数の制御をしたい` という相談を受けてサンプルを作ってみたのでその時のメモです。

# 前提

- php: 7.3
- laravel: 5.8
- mysql: 5.7

# ゴール

以下の要件を満たす処理サンプルを実装する

- 同時にログイン出来るユーザーは2人まで
- 判定は先勝ち
    - すでに2人ログインしていたらその2人のいずれかがログアウトするまでログイン出来ない

# 成果物

今回実装したサンプルはこちらに置いてあります
https://github.com/TsukasaGR/laravel-concurrent-login-control-sample

# 準備

Laravelだと認証の仕組みを作るまでは爆速で出来ますね。
本題ではないですが、さらっと記載しておきます。

## 1. Laravelインストール

インストール手順はたくさん情報があるのでコマンドだけ記載します。

```
# Laravelプロジェクトを作成
composer create-project --prefer-dist laravel/laravel laravel-concurrent-login-control-sample
# 作成したディレクトリに移動
cd laravel-concurrent-login-control-sample
# パッケージインストール
composer install
# env書き換え
# --- DBの設定を変更
```

## 2. 認証機能をインストール

認証もコマンド実装するだけですね。
まずは認証機能を入れます。

```
php artisan make:auth
```

あとはマイグレーションするだけです。

```
php artisan migrate
```

これで完成したのでアクセス確認してみると、

<img width="767" alt="スクリーンショット 2019-03-05 22.49.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/173370/470ebf6b-323c-6213-d2a1-75289a1a5505.png">

無事認証機能が追加されているのが確認出来ると思います。

# 同時ログイン実装

本題です。

同時ログインの実装方法ですが、今回はDBで制御しようと思います。
なんとかセッションの値使って出来ないかなと思ったんですが無理そうなので素直にDBで。

## 同時ログイン管理用テーブル作成

まずは同時ログイン管理用のテーブルを作成します。

```
php artisan make:migration create_logged_in_users_table --create=logged_in_users
```

```php:database/migrations/xxxx_xx_xx_xxxxxx_create_logged_in_users_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateLoggedInUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('logged_in_users', function (Blueprint $table) {
            $table->unsignedBigInteger('user_id');
            $table->timestamps();
            $table->foreign('user_id', 'f_logged_in_users_user_id')
                ->references('id')->on('users')->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('logged_in_users');
    }
}
```

これでマイグレーションを実行すれば完了です。

```
php artisan migrate
```

## ログイン後に管理用テーブルにデータを追加

制御の前に、ログインしたら管理用テーブルにデータを追加するようにします。

いくつか方法はあると思いますが、今回はmake:authで作成したログイン機能をそのまま流用したいのでAuth/LoginController.phpを修正します。

ポイントは、

1. 既存の認証処理はそのまま使い回す
2. ログイン後、画面に遷移する前にデータを差し込む

の2点です。

まず1点目の使い回しですが、 `AuthenticatesUsers.login` を使いまわしたいので、Traitのuseを以下のように変更します。

```diff:app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{

-   use AuthenticatesUsers;
+   use AuthenticatesUsers {
+       // loginメソッドを自身のコントローラーに持たせたいので元のメソッドに別名を付与する
+       login as _login;
+   }
+
+   // 自身のコントローラー側のメソッドを呼び出すようにする
+   public function login(Request $request)
+   {
+       // 処理は元のまま
+       return $this->_login($request);
+   }
```

これで使いまわしの準備が出来たので、あとはログイン後にデータを追加するだけです。

モデルを作成して、

```
php artisan make:model LoggedInUser
```

使えるようにします。

```php:app/LoggedInUser.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class LoggedInUser extends Model
{
    protected $fillable = ['user_id'];
}
```

モデルが使えるようになったら先程作成したloginメソッドを修正し、最終的にこのような形にします。

```php:app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

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

class LoginController extends Controller
{
    use AuthenticatesUsers {
        login as _login;
    }

    protected $redirectTo = '/home';

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

    public function login(Request $request)
    {
        // ログイン後と画面遷移の間に処理をはさみたいので返り値を一旦変数に入れる
        $response = $this->_login($request);

        // ログイン管理テーブルにデータを追加
        LoggedInUser::create([
            'user_id' => \Auth::id()
        ]);

        // レスポンスを返す
        return $response;
    }
}
```

これでログイン後に管理テーブルにデータを追加することが出来ました。

## ログアウト後に管理テーブルからデータを削除

こちらもログインと同様、既存の認証処理を使いまわします。
考え方は一緒で、以下の通りとします。

```php:app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

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

class LoginController extends Controller
{
    use AuthenticatesUsers {
        login as _login;
        logout as _logout;
    }

    protected $redirectTo = '/home';

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

    public function login(Request $request)
    {
        // ログイン後と画面遷移の間に処理をはさみたいので返り値を一旦変数に入れる
        $response = $this->_login($request);

        // ログイン管理テーブルにデータを追加
        LoggedInUser::create([
            'user_id' => \Auth::id()
        ]);

        // レスポンスを返す
        return $response;
    }

    public function logout(Request $request)
    {
        // ログアウト前にデータを削除
        LoggedInUser::where('user_id', \Auth::id())->delete();

        // 既存のログアウト処理を実行
        return $this->_logout($request);
    }
}
```

これでログアウト後に管理テーブルからデータを削除することが出来ました。

## 同時ログイン数を制御する

最後にログイン数の制御です。
ログインのタイミングで判定出来るようにしたいのでloginに対してFormRequestを作成します。

```
php artisan make:request LoginRequest
```

今回は入力値とは関係ないのでクロージャでバリデーションロジックを直接記入します。

```php:app/Http/Requests/LoginRequest.php
<?php

namespace App\Http\Requests;

use App\LoggedInUser;
use Illuminate\Foundation\Http\FormRequest;

class LoginRequest extends FormRequest
{
    public function authorize()
    {
        return \Auth::check();
    }

    public function rules()
    {
        return [
            'email' => [
                function ($attribute, $value, $fail) {
                    $numberOfLoginUser = LoggedInUser::count();
                    $isLoggedIn = LoggedInUser::where('user_id', \Auth::id())->exists();
                    // ログインユーザーが2名いる場合で、自身がまだログインしていなければエラーを返す
                    if ($numberOfLoginUser > 2 && !$isLoggedIn) {
                        return $fail(trans('validation.custom.limit-login')); // エラーメッセージは適宜修正
                    }
                }
            ]
        ];
    }
}
```

これで完成です。

# さいごに

今回はあくまでサンプル実装なので、

- ユーザー登録時も管理用テーブルにデータを入れる必要がある
- 同一ユーザーが再度ログインした場合はデータ追加でなく更新する必要がある
- ログアウトせずにユーザーが放置したことを考慮して定期的にリフレッシュする必要がある

等考慮する点はまだまだありますが、それでもここまで実装できていれば要件決めと決まったものをつらつら書くくらいで事足りるのではないかなと思います。

さくっと考えて試しただけですので、もっと良い方法がありましたらぜひご指摘頂けますと幸いです🙇