ログインするときにパスワードによる認証だけでなく、独自の制約を追加したい場合があると思います。
例えばユーザが仮登録ではない場合(本登録)のみログインとか、ある権限を有する場合のみログインなどです。
よくある話なので実装も何回か経験していますが、毎回調べながらなので今回記事としてまとめてみました。
とはいっても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
<?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');
});
}
}

今回の認証の仕様
単純にデフォルトの認証ならば、メールアドレスとパスワードのみでログイン出来ます。
そこに今回はdisabled
フラグを追加して、disabled = 0
ならばログインという仕様とします。
どうすれば認証処理で新たな制約を加えることが出来るのか。
認証のテスト
まずはテストを書いてみます。
$ php artisan make:test LoginTest
<?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
<?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
<?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
に記述されていますが、これがいまひとつ自分は理解出来ていません
SessionGuard
<?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
<?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()
メソッドで取得されています。
しかし、
/**
* 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
<?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)
成功しました
まとめ
今回はapp/Http/Controllers/Auth/LoginController.php
の修正だけで出来ました。
公式ドキュメントにはガードとプロバイダを使った独自の認証について書かれていますが、難しい。。。
なので、つい今回みたいな小手先の修正となってしまいます。
シンプルな仕様ならLaravelは超簡単ですが、複雑なシステムを作ろうとすると奥が深いですね。。(それはどれも同じでは^^;)
参考URL