Laravelの認証処理に独自の制約を追加する

ログインするときにパスワードによる認証だけでなく、独自の制約を追加したい場合があると思います。
例えばユーザが仮登録ではない場合(本登録)のみログインとか、ある権限を有する場合のみログインなどです。
よくある話なので実装も何回か経験していますが、毎回調べながらなので今回記事としてまとめてみました。
とはいってもLaravelのバージョンによって認証周りは結構違うんですよね。
今回試すのはLaravel 6.12.0です(もう12!!)。

https://laravel.com/docs/6.x/authentication
https://readouble.com/laravel/6.x/ja/authentication.html

# 準備

```sh
$ composer create-project --prefer-dist laravel/laravel auth-sample
$ cd auth-sample
$ composer require laravel/ui --dev
$ php artisan ui vue --auth
$ php artisan make:migration alter_table_users_add_column_disabled --table=users
$ php artisan migrate
$ yarn install
$ yarn run dev
$ php artisan serve
```

```database/migrations/alter_table_users_add_column_disabled.php 
<?php

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

class AlterTableUsersAddColumnDisabled extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->tinyInteger('disabled')->default(0)->after('remember_token');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('disabled');
        });
    }
}
```

<img width="1128" alt="スクリーンショット 2020-01-22 15.46.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123511/10c9096c-8f55-a413-5d72-5fe17790ec56.png">

# 今回の認証の仕様

単純にデフォルトの認証ならば、メールアドレスとパスワードのみでログイン出来ます。
そこに今回は`disabled`フラグを追加して、`disabled = 0`ならばログインという仕様とします。
どうすれば認証処理で新たな制約を加えることが出来るのか。

# 認証のテスト

まずはテストを書いてみます。

```sh
$ php artisan make:test LoginTest
```

```tests/Feature/LoginTest.php 
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class LoginTest extends TestCase
{
    use RefreshDatabase;

    /**
     * 間違ったパスワードのログイン失敗のテスト
     *
     * @return void
     */
    public function testInvalidPasswordLogin()
    {
        $user = factory(\App\User::class)->create(['email' => 'dummy@test.jp', 'disabled' => 0]);

        $response = $this->get('/login');

        $response->assertStatus(200);

        $response = $this->post('/login', ['email' => 'dummy@test.jp', 'password' => 'dummy']);

        $response->assertStatus(302);
        $response->assertRedirect('/login'); // 認証不可なのでログイン画面にリダイレクト
        $this->assertFalse($this->isAuthenticated('web'));
    }

    /**
     * ログイン成功のテスト
     *
     * @return void
     */
    public function testLogin()
    {
        $user = factory(\App\User::class)->create(['email' => 'enable@test.jp', 'disabled' => 0]);

        $response = $this->post('/login', ['email' => 'enable@test.jp', 'password' => 'password']);

        $response->assertStatus(302);
        $response->assertRedirect('/home');
        $this->assertAuthenticatedAs($user, $guard = 'web');
    }

    /**
     * disabled = 1 のログイン失敗のテスト
     *
     * @return void
     */
    public function testDisableLogin()
    {
        $user = factory(\App\User::class)->create(['email' => 'disable@test.jp', 'disabled' => 1]);

        $response = $this->get('/login');

        $response->assertStatus(200);

        $response = $this->post('/login', ['email' => 'disable@test.jp', 'password' => 'password']);

        $response->assertStatus(302);
        $response->assertRedirect('/login'); // 認証不可なのでログイン画面にリダイレクト
        $this->assertFalse($this->isAuthenticated('web'));
    }
}
```

```sh
$ vendor/bin/phpunit tests/Feature/LoginTest.php 
PHPUnit 8.5.2 by Sebastian Bergmann and contributors.

..F                                                                 3 / 3 (100%)

Time: 326 ms, Memory: 24.00 MB

There was 1 failure:

1) Tests\Feature\LoginTest::testDisableLogin
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'http://localhost/login'
+'http://localhost/home'

/Users/takahashikiyoshi/dev/valet/auth-sample/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:259
/Users/takahashikiyoshi/dev/valet/auth-sample/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:204
/Users/takahashikiyoshi/dev/valet/auth-sample/tests/Feature/LoginTest.php:65

FAILURES!
Tests: 3, Assertions: 15, Failures: 1.
```

`disabled = 1`のユーザが、認証不可で`/login`にリダイレクトされなければいけないところを、正常にログインして`/home`にリダイレクトされています。
このテストが成功するように修正していきます。

# 認証処理のソースコードを追う

ログインのソースコードを追ってみましょう。

## LoginController

```app/Http/Controllers/Auth/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');
    }
}
```

ログインのコントローラでは`AuthenticatesUsers`トレイトが使われていますね。

## AuthenticatesUsers

```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()`メソッドの中で`attemptLogin()`メソッド、`Auth::guard()`の`attempt()`が呼ばれています。
`attempt()`を調べたら`vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php`に行き着きました。
認証のガードやプロバイダは、`config/auth.php`に記述されていますが、これがいまひとつ自分は理解出来ていません:cry:

## SessionGuard

```vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
<?php

namespace Illuminate\Auth;

class SessionGuard implements StatefulGuard, SupportsBasicAuth
{

    // 省略

    /**
     * Attempt to authenticate a user using the given credentials.
     *
     * @param  array  $credentials
     * @param  bool  $remember
     * @return bool
     */
    public function attempt(array $credentials = [], $remember = false)
    {
        $this->fireAttemptEvent($credentials, $remember);

        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

        // If an implementation of UserInterface was returned, we'll ask the provider
        // to validate the user against the given credentials, and if they are in
        // fact valid we'll log the users into the application and return true.
        if ($this->hasValidCredentials($user, $credentials)) {
            $this->login($user, $remember);

            return true;
        }

        // If the authentication attempt fails we will fire an event so that the user
        // may be notified of any suspicious attempts to access their account from
        // an unrecognized user. A developer may listen to this event as needed.
        $this->fireFailedEvent($user, $credentials);

        return false;
    }

    // 省略
}
```

`retrieveByCredentials()`で認証情報からユーザを取得しています。
次に出てくるのが、Eloquentを使っているなら`vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php`です。

## EloquentUserProvider

```vendor/laravel/framework/src/Illuminate/Auth/EloquentUserProvider.php
<?php

namespace Illuminate\Auth;

class EloquentUserProvider implements UserProvider
{

    // 省略

    /**
     * Retrieve a user by the given credentials.
     *
     * @param  array  $credentials
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveByCredentials(array $credentials)
    {
        if (empty($credentials) ||
           (count($credentials) === 1 &&
            array_key_exists('password', $credentials))) {
            return;
        }

        // First we will add each credential element to the query as a where clause.
        // Then we can execute the query and, if we found a user, return it in a
        // Eloquent User "model" that will be utilized by the Guard instances.
        $query = $this->newModelQuery();

        foreach ($credentials as $key => $value) {
            if (Str::contains($key, 'password')) {
                continue;
            }

            if (is_array($value) || $value instanceof Arrayable) {
                $query->whereIn($key, $value);
            } else {
                $query->where($key, $value);
            }
        }

        return $query->first();
    }

    // 省略
}
```

よく知られているように？、Laravelの認証では、まずパスワード以外の情報でユーザを取得してきてから、パスワードをチェックします。
ということは、`retrieveByCredentials(array $credentials)`の`$credentials`に`disabled = 0`の条件を渡せればいいわけです。
遡って`$credentials`はどう取得されるかというと、`AuthenticatesUsers`トレイトの`credentials()`メソッドで取得されています。
しかし、

```php
    /**
     * 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');
    }
```

`username()`で定義された項目(デフォルトではメールアドレス)とパスワードしか返さない実装となっています。
ここをコントローラでオーバーライドすればいいのでは！？

## 修正後LoginController

```app/Http/Controllers/Auth/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;

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

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

はたして、、、

```sh
$ vendor/bin/phpunit tests/Feature/LoginTest.php 
PHPUnit 8.5.2 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 283 ms, Memory: 24.00 MB

OK (3 tests, 16 assertions)
```

成功しました:beer:

# まとめ

今回は`app/Http/Controllers/Auth/LoginController.php`の修正だけで出来ました。
公式ドキュメントにはガードとプロバイダを使った独自の認証について書かれていますが、難しい。。。
なので、つい今回みたいな小手先の修正となってしまいます。
シンプルな仕様ならLaravelは超簡単ですが、複雑なシステムを作ろうとすると奥が深いですね。。(それはどれも同じでは^^;)

# 参考URL

https://qiita.com/tomoeine/items/40a966bf3801633cf90f