【PHP】Laravel6で同時ログイン制御（先勝ち）をする方法を考えてみた

#この記事について
この間に投稿した記事でLaravelでは認証機能（ログイン機能）を設定するのは簡単だと説明しました。
[【PHP】Laravel6で遊ぶ（認証機能のセットアップ）](https://qiita.com/666mikoto/items/f9462235a5646669f7e5)

認証機能で考えてみたいのが、『同時ログイン制御』です。
少し調べたところ、あまり同時ログイン制御に関する記事やサイトがないような感じです。
本記事では先勝ちで同時ログインを禁止する方法を検討してみます。

#環境
OS：Windows10 Home
PHP：7.4.15（XAMPP)
Laravel：6.20.16

#実現方法
昨日の記事でSessionの管理方法について触れてみました。
[【PHP】Laravelのセッション管理を勉強する](https://qiita.com/666mikoto/items/c6949bf4e3722f1c7105)

この記事では、Session情報をDBで管理する方法を紹介し、```Sessions```テーブルに以下の項目が管理できています。

| カラム名      | 内容        |
|:------------:|:------------|
| id           | セッションID |
| user_id      | ログインしているユーザーのID（NULLの場合は未ログイン）|
| ip_address   | IPアドレス   |
| user_agent   | ユーザーエージェント|
| payload      | いろいろなデータ（適当）|
| last_activity| 最終行動時間（UNIX時間）|

実現方法としては、ログイン処理後の遷移前にログインしようとしているユーザーのIDが```Sessions```テーブルに存在するかどうかを調べます。
存在しない場合はログインさせ、存在する場合はログイン画面に戻す処理で考えてみます。

#修正するファイルについて
実現方法のイメージはできたので、どのファイルをいじればいいのかを考えてみます。
ログイン画面のHTMLを確認すると、FormのPOSTメソッドでactionが```（ドメイン）/login```になっています。
その場合にどのような処理が走るかは以下のコマンドで確認できます。

```:command
php artisan route:list
```
```:result
+--------+----------+------------------------+------------------+------------------------------------------------------------------------+--------------+
| Domain | Method   | URI                    | Name             | Action                                                                 | Middleware   |
+--------+----------+------------------------+------------------+------------------------------------------------------------------------+--------------+
|        | GET|HEAD | /                      |                  | Closure                                                                | web          |
|        | GET|HEAD | api/user               |                  | Closure                                                                | api,auth:api |
|        | GET|HEAD | home                   | home             | App\Http\Controllers\HomeController@index                              | web,auth     |
|        | GET|HEAD | login                  | login            | App\Http\Controllers\Auth\LoginController@showLoginForm                | web,guest    |
|        | POST     | login                  |                  | App\Http\Controllers\Auth\LoginController@login                        | web,guest    |
|        | POST     | logout                 | logout           | App\Http\Controllers\Auth\LoginController@logout                       | web          |
|        | GET|HEAD | password/confirm       | password.confirm | App\Http\Controllers\Auth\ConfirmPasswordController@showConfirmForm    | web,auth     |
|        | POST     | password/confirm       |                  | App\Http\Controllers\Auth\ConfirmPasswordController@confirm            | web,auth     |
|        | POST     | password/email         | password.email   | App\Http\Controllers\Auth\ForgotPasswordController@sendResetLinkEmail  | web          |
|        | GET|HEAD | password/reset         | password.request | App\Http\Controllers\Auth\ForgotPasswordController@showLinkRequestForm | web          |
|        | POST     | password/reset         | password.update  | App\Http\Controllers\Auth\ResetPasswordController@reset                | web          |
|        | GET|HEAD | password/reset/{token} | password.reset   | App\Http\Controllers\Auth\ResetPasswordController@showResetForm        | web          |
|        | GET|HEAD | register               | register         | App\Http\Controllers\Auth\RegisterController@showRegistrationForm      | web,guest    |
|        | POST     | register               |                  | App\Http\Controllers\Auth\RegisterController@register                  | web,guest    |
+--------+----------+------------------------+------------------+------------------------------------------------------------------------+--------------+
```

上記のようなRouteの情報は```route/web.php```ファイルで確認することもできます。

```PHP:route/web.php
<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');
```
ただ、認証機能をセットアップすると```Auth::routes();```で認証関係のRouteがまとめられているので、コマンドで調べました。

コマンドの結果に戻ります。
ログイン処理時には```/login```にPOSTメソッドで遷移します。
コマンド結果を見てみると・・・

```
+--------+----------+------------------------+------------------+------------------------------------------------------------------------+--------------+
| Domain | Method   | URI                    | Name             | Action                                                                 | Middleware   |
+--------+----------+------------------------+------------------+------------------------------------------------------------------------+--------------+
|        | POST     | login                  |                  | App\Http\Controllers\Auth\LoginController@login  
```
これですね。
アクションとしては、```LoginController```の```login```メソッドに処理を渡していることが分かります。
なので、```App\Http\Controllers\Auth\LoginController.php```を修正すればよさそうですね。

#loginメソッドの確認
早速、```LoginController.php```を確認してみます。

```PHP:LoginController.php
<?php

namespace App\Http\Controllers\Auth;

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

class LoginController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
    */

    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = RouteServiceProvider::HOME;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }
}
```
あれ？loginメソッドは？ってなりました。
実際は上記ファイルに```login```メソッドの詳細は記載されていません。
```AuthenticatesUsers```というのがポイントで、```Illuminate/Foundation/Auth/AuthenticatesUsers```に記載されています。
ちなみに、```Illuminate```は```/vendor/laravel/framework/src/Illuminate```を指しています。

```PHP:/vendor/laravel/framework/src/Illuminate/Foundation/Auth/AuthenticatesUsers.php
<?php

namespace Illuminate\Foundation\Auth;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

trait AuthenticatesUsers
{
    use RedirectsUsers, ThrottlesLogins;

    /**
     * Show the application's login form.
     *
     * @return \Illuminate\Http\Response
     */
    public function showLoginForm()
    {
        return view('auth.login');
    }

    /**
     * Handle a login request to the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function login(Request $request)
    {
        $this->validateLogin($request);

        // If the class is using the ThrottlesLogins trait, we can automatically throttle
        // the login attempts for this application. We'll key this by the username and
        // the IP address of the client making these requests into this application.
        if (method_exists($this, 'hasTooManyLoginAttempts') &&
            $this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

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

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

        // If the login attempt was unsuccessful we will increment the number of attempts
        // to login and redirect the user back to the login form. Of course, when this
        // user surpasses their maximum number of attempts they will get locked out.
        $this->incrementLoginAttempts($request);

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

    /**
     * Validate the user login request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return void
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function validateLogin(Request $request)
    {
        $request->validate([
            $this->username() => 'required|string',
            'password' => 'required|string',
        ]);
    }

    /**
     * Attempt to log the user into the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function attemptLogin(Request $request)
    {
        return $this->guard()->attempt(
            $this->credentials($request), $request->filled('remember')
        );
    }

    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only($this->username(), 'password');
    }

    /**
     * Send the response after the user was authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    protected function sendLoginResponse(Request $request)
    {
        $request->session()->regenerate();

        $this->clearLoginAttempts($request);

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

    /**
     * The user has been authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  mixed  $user
     * @return mixed
     */
    protected function authenticated(Request $request, $user)
    {
        //
    }

    /**
     * Get the failed login response instance.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function sendFailedLoginResponse(Request $request)
    {
        throw ValidationException::withMessages([
            $this->username() => [trans('auth.failed')],
        ]);
    }

    /**
     * Get the login username to be used by the controller.
     *
     * @return string
     */
    public function username()
    {
        return 'email';
    }

    /**
     * Log the user out of the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function logout(Request $request)
    {
        $this->guard()->logout();

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

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

        return $this->loggedOut($request) ?: redirect('/');
    }

    /**
     * The user has logged out of the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return mixed
     */
    protected function loggedOut(Request $request)
    {
        //
    }

    /**
     * Get the guard to be used during authentication.
     *
     * @return \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected function guard()
    {
        return Auth::guard();
    }
}
```
長いですが、loginメソッドが記載されていましたね。
内容までは解説しませんが・・・

ただ、```login```メソッドが上記ファイルに記載されているとは言っても、上記ファイルを編集することはおススメしません。
```vendor```フォルダにはComposerでインストールしたライブラリ等が含まれるので、影響範囲が大きいです。
処理の制御はControllerで行うのが基本になりますので、```LoginController```の方を修正することにします。

#LoginControllerの修正
以下のように修正してみました。

```PHP:LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request; //追加
use Illuminate\Support\Facades\Auth; //追加
use Illuminate\Support\Facades\DB; //追加


class LoginController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
    */

    //以下を修正
    use AuthenticatesUsers{
    	login as _login;
    }

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = RouteServiceProvider::HOME;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }
    
    //以下を追記
    public function login(Request $request){

        $response = $this->_login($request);

        $user_id = Auth::id();

        $count = DB::table("sessions")
        ->where("user_id", $user_id)
        ->count();

        if ($count === 0){
            return $response;
        }
        else{
            Auth::logout();
            \Session::flash("message", "他のユーザーがログインしています。時間を置いて再ログインしてください。");
            return view("auth.login");
        }
    }
}
```

##解説
```PHP
    use AuthenticatesUsers{
    	login as _login;
    }
```
これによって、後述する自作のloginメソッドに向くようにしています。

```PHP
    public function login(Request $request){

        $response = $this->_login($request);

        $user_id = Auth::id();

        $count = DB::table("sessions")
        ->where("user_id", $user_id)
        ->count();

        if ($count === 0){
            return $response;
        }
        else{
            Auth::logout();
            \Session::flash("message", "他のユーザーがログインしています。時間を置いて再ログインしてください。");
            return view("auth.login");
        }
    }
```
こちらが追記した```login```メソッドです。
リネームした```_login```メソッドを実行して```return```の前に分岐を入れています。

ここでは詳しい説明はしませんが、Authファザードを使ってログインしたユーザーのIDを取得して、DBファザードを使ってSQLの実行をしています。

既にログインされている場合は、自信をログアウトさせてログイン画面に遷移するようにしています。

```PHP
\Session::flash("message", "他のユーザーがログインしています。時間を置いて再ログインしてください。");
```
ちなみに、フラッシュと言って、遷移先に1回だけメッセージを表示することができます。

ということで、メッセージを表示する```login.blade.php```にメッセージ表示するよう追記します。

```PHP:/resources/views/auth/login.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <!--追記-->
            @if (session('message'))
                <div class="alert alert-warning">
                    {{ session('message') }}
                </div>
            @endif
            <!--ここまで-->
            <div class="card">
                <div class="card-header">{{ __('Login') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('login') }}">
                        @csrf

                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus>

                                @error('email')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="current-password">

                                @error('password')
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $message }}</strong>
                                    </span>
                                @enderror
                            </div>
                        </div>

                        <div class="form-group row">
                            <div class="col-md-6 offset-md-4">
                                <div class="form-check">
                                    <input class="form-check-input" type="checkbox" name="remember" id="remember" {{ old('remember') ? 'checked' : '' }}>

                                    <label class="form-check-label" for="remember">
                                        {{ __('Remember Me') }}
                                    </label>
                                </div>
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Login') }}
                                </button>

                                @if (Route::has('password.request'))
                                    <a class="btn btn-link" href="{{ route('password.request') }}">
                                        {{ __('Forgot Your Password?') }}
                                    </a>
                                @endif
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
```

#検証結果
Chromeでログインしてみる（1人目）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/250196/d703e5b1-11ce-ceb3-6054-0599df32ca89.png)
1人目としてなので、ログインできてOKです！

Firefoxでログインしてみる（2人目）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/250196/4a89c80c-5401-d443-9aea-664a36f11c44.png)
2人目なのでログインできずに、未ログインの状態でログイン画面に戻っていますね！
メッセージも表示されていていい感じです！

#完成？
上記内容でうまく同時ログイン制御ができています。
ただ、一つ考慮しないといけないことがあります。
この内容でうまく処理ができるのは「ログアウト処理をちゃんとしている」場合です。

よくあると思いますが、皆さんログアウト処理をせずにブラウザのタブを閉じたり、ブラウザ自体を閉じたりして作業を終了させること多いと思います。
その場合、```sessions```テーブルの`user_is```に値が入ったままで残り続けます・・・
そうなると、一生ログインできなくなってしまいます・・・

#もう1つの条件
ここで使うのが```last_activity```カラムです。
このカラムには最終行動したUNIX時間が格納されます。

なので、例えば20分操作しなかった場合にログアウトしたとみなすものとします。
それならば、ログインできる条件を「自アカウントでログインしている、かつ、現在時刻と最終行動時間の差が1200秒以内であるユーザーが0人」とすれば良いと考えます。

なので、以下のように```LoginController```の```login```メソッドに条件を追加します。

```PHP
    public function login(Request $request){

        $response = $this->_login($request);

        $user_id = Auth::id();
        $time = time(); //現在時刻のUNIX時間

        $count = DB::table("sessions")
        ->where("user_id", $user_id)
        ->where("last_activity", ">", $time-1200) //条件(AND)追加
        ->count();

        if ($count === 0){
            return $response;
        }
        else{
            Auth::logout();
            \Session::flash("message", "他のユーザーがログインしています。時間を置いて再ログインしてください。");
            return view("auth.login");
        }
    }
```
こんな感じでしょうか。
ちなみに「20分操作しなかった場合にログアウトしたとみなす」とする場合はLaravelの```.env```および、```config/session.php```の```SESSION_LIFETIME```も20分にして合わせておくのがベストです。
逆にそうしないと2人ログインできている状態にもできるので、同時ログイン制御の目的は達成できないですね。

#最後に
後勝ちの同時ログイン制御は```logoutOtherDevices```を使えるので比較的楽に実装できそうですが、
先勝ちの場合はどうしようかと思って考えついた方法です。
もっと楽な方法、リスクが少ない方法があれば教えて欲しいです^^

#参考記事
[Laravelで同時ログイン数を制御をする](https://qiita.com/TsukasaGR/items/2cfbb24bc46fd7247eca)