LoginSignup
3
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-19

この記事について

この間に投稿した記事でLaravelでは認証機能(ログイン機能)を設定するのは簡単だと説明しました。
【PHP】Laravel6で遊ぶ(認証機能のセットアップ)

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

環境

OS:Windows10 Home
PHP:7.4.15(XAMPP)
Laravel:6.20.16

実現方法

昨日の記事でSessionの管理方法について触れてみました。
【PHP】Laravelのセッション管理を勉強する

この記事では、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ファイルで確認することもできます。

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  

これですね。
アクションとしては、LoginControllerloginメソッドに処理を渡していることが分かります。
なので、App\Http\Controllers\Auth\LoginController.phpを修正すればよさそうですね。

loginメソッドの確認

早速、LoginController.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を指しています。

/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の修正

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

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");
        }
    }
}

解説

    use AuthenticatesUsers{
        login as _login;
    }

これによって、後述する自作のloginメソッドに向くようにしています。

    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の実行をしています。

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

\Session::flash("message", "他のユーザーがログインしています。時間を置いて再ログインしてください。");

ちなみに、フラッシュと言って、遷移先に1回だけメッセージを表示することができます。

ということで、メッセージを表示するlogin.blade.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
1人目としてなので、ログインできてOKです!

Firefoxでログインしてみる(2人目)
image.png
2人目なのでログインできずに、未ログインの状態でログイン画面に戻っていますね!
メッセージも表示されていていい感じです!

完成?

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

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

もう1つの条件

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

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

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

    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.phpSESSION_LIFETIMEも20分にして合わせておくのがベストです。
逆にそうしないと2人ログインできている状態にもできるので、同時ログイン制御の目的は達成できないですね。

最後に

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

参考記事

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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0