18
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

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

準備

$ 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');
        });
    }
}
スクリーンショット 2020-01-22 15.46.55.png

今回の認証の仕様

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

認証のテスト

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

$ 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'));
    }
}
$ 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)$credentialsdisabled = 0の条件を渡せればいいわけです。
遡って$credentialsはどう取得されるかというと、AuthenticatesUsersトレイトのcredentials()メソッドで取得されています。
しかし、

    /**
     * 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;
    }
}

はたして、、、

$ 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

18
17
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
18
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?