【Laravel】ログイン失敗時、アカウントロックの代わりに何か別の処理をする

Laravelって便利なもので、LoginControllerにプロパティを設定するだけでアカウントロック機能を実装できちゃいます。
（LoginControllerに設定しなくてもデフォルトで、5回のログイン失敗を許容し、6回失敗したら1分間ロックされるようになっています）

```php:LoginController.php
// 下記の場合だと、10回失敗OK、11回失敗したら5分間アカウントがロックされます
protected $maxAttempts = 10;
protected $decayMinutes = 5;
```
この記事は、そんなLaravelでデフォルトで設定されているアカウントロックをせずに、別の処理を出来るようにカスタマイズしちゃおう！というものです。

↓バージョンなど
Laravel >= 5.6
PHP >= 7.1

# そもそもアカウントロックってどこで設定されてる？
どう処理が走ってアカウントロックが起こるのか、まず確認してみます。
ログイン処理の実装周りは、`Illuminate/Foundation/Auth/AuthenticatesUsers.php`に書かれています。

```php:AuthenticatesUsers.php
    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 ($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);
    }
```
これを見ると、`sendLockoutResponse `がアカウントロックの処理をしてそうだと推測できます。
ということで`sendLockoutResponse`がどうなっているのか確認します。
このメソッドはコメントアウトにもあるように、`AuthenticatesUsers.php`で使用している`ThrottlesLogins`トレイトに書かれています。

```php:Illuminate/Foundation/Auth/ThrottlesLogins.php
    protected function sendLockoutResponse(Request $request)
    {
        $seconds = $this->limiter()->availableIn(
            $this->throttleKey($request)
        );

        throw ValidationException::withMessages([
            $this->username() => [Lang::get('auth.throttle', ['seconds' => $seconds])],
        ])->status(429);
    }
```
コードの内容を見ると、やはりここでアカウントロックをかけているようです！（ValidationExceptionが投げられている）
では次にこの`sendLockoutResponse`メソッドが実行される条件である`hasTooManyLoginAttempts`メソッドを確認してみましょう。

```php:Illuminate/Foundation/Auth/ThrottlesLogins.php
    protected function hasTooManyLoginAttempts(Request $request)
    {
        return $this->limiter()->tooManyAttempts(
            $this->throttleKey($request), $this->maxAttempts()
        );
    }
```
うーん、これだけでは分からないですね。。。
`tooManyAttempts`の処理如何のようです。ちなみに、その引数に渡されている`$this->throttleKey($request)`と`$this->maxAttempts()`はそれぞれ「ユーザー名（email）+ IP」と「ログイン試行可能回数」を返します。

では`tooManyAttempts`を確認していきますが、これは`RateLimiter`クラスに書かれています。（`$this->limiter()`はそれをインスタンス化するメソッド）

```php:Illuminate/Cache/RateLimiter.php
    public function tooManyAttempts($key, $maxAttempts)
    {
        if ($this->attempts($key) >= $maxAttempts) {
            if ($this->cache->has($key.':timer')) {
                return true;
            }

            $this->resetAttempts($key);
        }

        return false;
    }
```
`$this->attempts($key)`は同じクラス内に書かれているのですが、各ユーザーがキャッシュとして保持しているログイン試行回数を返します。
なのでこのコードを見ると、プロパティに設定されたログイン試行可能回数以上にログインを試みて、且つ`$key.':timer'`をキーとするキャッシュを保持している場合のみ、`sendLockoutResponse`メソッドは実行されるようです。

では、`$key.':timer'`をキーとして値を保存させている処理はどうなっているのでしょう？
それは、同じクラス内にありました。

```php:Illuminate/Cache/RateLimiter.php
    public function hit($key, $decayMinutes = 1)
    {
        $this->cache->add(
            $key.':timer', $this->availableAt($decayMinutes * 60), $decayMinutes
        );

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

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

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

        return $hits;
    }
```
`$this->cache->add`で設定してますね。ログイン試行回数もここで保存してます。
ただここで問題が起こる事が分かります。それはこのキャッシュの保持時間が`$decayMinutes`であること。
何か嫌な予感がしますね。。。
ということでこのメソッドが`ThrottlesLogins`トレイトでどう使われているか確認してみましょう。

```php:ThrottlesLogins.php
    protected function incrementLoginAttempts(Request $request)
    {
        $this->limiter()->hit(
            $this->throttleKey($request), $this->decayMinutes()
        );
    }
```
嫌な予感的中！
`$this->decayMinutes()`は`$decayMinutes`プロパティを返すメソッドです。
つまり、`$decayMinutes`を0に設定してしまうと、「アカウントロックせずに別の処理を走らせる」という事が出来ません。。。
（`hasTooManyLoginAttempts`メソッドがtrueを返すことが無いので）

# アカウントロックせずに別の処理を走らせたい！
では本題。
上記の調査で、`$decayMinutes`を0に設定してしまうと、アカウントロックせずに別の処理を走らせられない事が分かりました。
なので、`hasTooManyLoginAttempts`メソッドをLoginController内でオーバーライドして、一定の条件下ではtrueを返すようにしましょう！
`hasTooManyLoginAttempts`の中身は実質`tooManyAttempts`メソッドであるので、これを参考にコードを書いていきます。

```php:LoginController.php
    protected function hasTooManyLoginAttempts(Request $request)
    {
        $attemptCount = \Cache::get($this->throttleKey($request));
        if ($attemptCount >= $this->maxAttempts()) {
            $credentials = $request->only('email', 'password');

            if (! \Auth::attempt($credentials)) {
                return true;
            }

            \Cache::forget($this->throttleKey($request));
        }
        return false;
    }
```
`tooManyAttempts`と異なる点は、ネストされたif文の条件式です。
Authファサードの`attempt`メソッドを利用して、認証できればfalse、出来なければtrueが返るようにします。
こうする事で`sendLockoutResponse`メソッドを実行できるようになります！
最後に、`sendLockoutResponse`もオーバーライドしておきましょう。そうしないと、ログイン試行可能回数を超えた時に、「0 - 現在時刻」待たなければいけません。
「-1566617015秒お待ちください」とか表示されます！笑

```php:LoginController.php
    protected function sendLockoutResponse(Request $request)
    {
        // 何かしたい処理を書く。
    }
```

いやー、Laravelのvendorディレクトリ以下を見ていくのってちょっと億劫だったのですが、今回の学びで少し勇気が出ました。