Laravel 8で一般ログインと管理ログインを分けるために、マルチ認証を実装する方法を紹介します。ここで紹介する方法は従来のUIを用いる方式で、Jetstream、Fortify、Breezeを用いる方式ではありません。
ゴール
- 一般ログインと管理ログインを分ける。
- Laravel標準のユーザー認証機能(Auth)を使う。
- コードはなるべく共用にする。
- 一般ログインURIは
/login
とする。 - 管理ログインURIは
/login/admin
とし、管理画面は/admin
以下とする。 - リセットパスワードができるようにする。
前提
- PHP 8の知識がある
- Laravel 8の知識がある、認証機能のあるアプリが作れる
- Laravel 8に必要な環境構築ができる
- Composerが使える状態にある
- Laravel Installerが使える状態にある
Laravelで認証機能のあるアプリを作る知識と環境がある前提でスタートします。今回は一般ユーザー(user)と管理ユーザー(admin)の2種類の認証ができるようにします。3種類以上でも全く同じ方法で拡張できます。
Windowsで作っているので、MacやLinuxで合わないところは読み替えてください。
ベースとなるアプリを作る
まずはLaravel 8の multi-auth
プロジェクトをサクッと作ります。
$ composer create-project laravel/laravel multi-auth "8.*"
$ cd multi-auth
データベースを作って、 .env
に接続設定します。
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=qiita_multi_auth
DB_USERNAME=dbuser
DB_PASSWORD=password
動作することだけ確認しておきます。以下で簡易サーバー起動後、ブラウザで http://localhost
を確認。
$ php artisan serve --port=80
npm run dev ができる準備
npm run dev
できるように、cross-env
を入れておきます。
$ npm install && npm install --save-dev cross-env
Migrationを作る
Laravel標準で users
テーブルのMigrationは準備されていますが、今回は管理ユーザーを別テーブルとしたいので admins
テーブルを作ります。これにより、同じEmailアドレスを使って一般ユーザーと管理ユーザー両方のアカウントを正しく作成できます。
$ php artisan make:migration create_admins_table
中身は users
テーブルとほぼ同じです。必要に応じて追加情報を加えても良いでしょう。ソフトデリートにしなくても大丈夫です。
class CreateAdminsTable extends Migration
{
[...]
public function up()
{
Schema::create('admins', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->boolean('is_super')->default(false);
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
}
[...]
}
テーブルを作る
必要なテーブルのMigration定義はOKなので、早速テーブルを作ります。
$ php artisan migrate
Modelを作る
admins
の Model を作ります。
$ php artisan make:model Admin
内容は、 admins
テーブルに合わせて以下のように作ります。
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Admin extends Authenticatable
{
use Notifiable;
protected $guard = 'admin';
protected $fillable = [
'name', 'email', 'password',
];
protected $hidden = [
'password', 'remember_token',
];
}
Admin
モデルは Model
クラスではなく Illuminate\Foundation\Auth\User
クラスを継承します。冒頭で名前を Authenticatable
としています。また、 use Notifiable;
でメールを送れるようにしておきます。このあたりの構造は User
モデルが参考になります。
User
モデルとの違いとして、ユーザー認証をデフォルトのユーザーガードではなく admin
という名前の別ガードにしたいので、 $guard
を設定しています。
Laravel 8から、デフォルトの User
モデルには use HasFactory
がついてます。今回 Admin
モデルもこれにならって HasFactory
も use に入れて実際にFactoryを作るほうが行儀が良いですが、本題から外れるのでここでは省略します。Factoryに関して詳しくはこちら。
Guardを作る
先程設定した admin
ガードを作ります。ガード追加は config/auth.php
に定義するだけです。
'guards' => [
[...]
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
],
これで admin
ガードに admins
プロバイダーを設定しました。プロバイダーでは、どのモデルを認証に使うべきかなどが設定されます。 admins
プロバイダーを同じファイルに追加します。今回は users
プロバイダー同様に同じデータベース上の情報を使うので、同じ設定をモデル名だけ変更して使います。
'providers' => [
[...]
'admins' => [
'driver' => 'eloquent',
'model' => App\Admin::class,
],
],
認証機能のための画面を導入
Laravel 8ではデフォルトの状態でLaravelの認証関連機能が使える状態になっていますが、認証関連のデフォルト画面などは使える状態になっていません。標準のUIパッケージの中に認証関連のControllerやView(=Blade)が入っているので、まずUIパッケージを入れます。Laravel 8用のUIパッケージバージョンは3なので、これを指定して入れます。
$ composer require laravel/ui="3.*"
次に認証関連のViewなどをUIのコマンドで作成します。デフォルトで実行すると、ついでに最低限必要なルートやログイン後のホームページ(HomeController@index
)を作ってくれます。
もしreactではなくvueを使いたい場合は、コマンド中のreactをvueにして実行します。
$ php artisan ui react --auth
最後にリソースの取得とコンパイルをします。
$ npm install && npm run dev
ここで http://localhost/login
を確認すると、ログイン画面が表示されるはずです。
また、トップページ http://localhost
の右上には以下のようにログインとユーザー登録のリンクが出ます。
コントローラを修正する
ログインとユーザー登録では、ユーザーが開けたページのURLからルーター経由でメンバメソッドが呼び出されるため、ガードごとにメソッドを分けることができ、1つのコントローラファイル内で複数のガードの動作を別メソッドとして書くことができます。まずはこの2つを実装します。
ログインコントローラの修正
public function __construct()
{
$this->middleware('guest')->except('logout');
$this->middleware('guest:admin')->except('logout');
}
ログインコントローラに、 admin
ガードのゲストとしてのアクセスを許可するミドルウェアを登録します。これにより、デフォルトのユーザーログインまたは管理ログインをしている場合に、ゲスト扱いされずこのコントローラへのアクセスを拒否できます。これによって、一般ログインと管理ログインを同時にしている状態を防げます。
次に、管理ログイン用の動作を追加します。
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
[...]
/**
* 管理者ログイン用
*/
public function showAdminLoginForm()
{
return view('auth.login', ['authgroup' => 'admin']);
}
public function adminLogin(Request $request)
{
$this->validate($request, [
'email' => 'required|email',
'password' => 'required|min:8'
]);
// 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 (Auth::guard('admin')->attempt(['email' => $request->email, 'password' => $request->password], $request->get('remember'))) {
return redirect()->intended('/admin');
}
// 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 back()->withInput($request->only('email', 'remember'));
}
この2つのメソッドは、 \vendor\laravel\ui\auth-backend\AuthenticatesUsers.php
の showLoginForm()
と login()
メソッドを書き換えたものです。
フォームの表示をするときに、追加パラメータとして authgroup
に admin
を設定して置きます。これにより、管理ユーザー用にフォーム表示する場合にはブレード側で表示内容を変更できるようにします。これにより、ログインフォームの送信先メソッドを、一般ユーザーで使う login()
ではなく adminLogin()
にすることを狙っています。
ユーザー登録コントローラの修正
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
[...]
public function __construct()
{
$this->middleware('guest');
$this->middleware('guest:admin');
}
ユーザー登録コントローラも LoginController
同様にガードコントロールのミドルウェアを登録しておきます。
use App\Models\Admin;
use Illuminate\Auth\Events\Registered;
[...]
/**
* 管理者ログイン用
*/
protected function adminValidator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:admins'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
}
public function showAdminRegisterForm()
{
return view('auth.register', ['authgroup' => 'admin']);
}
public function registerAdmin(Request $request)
{
$this->adminValidator($request->all())->validate();
event(new Registered($user = $this->createAdmin($request->all())));
Auth::guard('admin')->login($user);
if ($response = $this->registeredAdmin($request, $user)) {
return $response;
}
return $request->wantsJson()
? new JsonResponse([], 201)
: redirect(route('admin-home'));
}
protected function createAdmin(array $data)
{
return Admin::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
protected function registeredAdmin(Request $request, $user)
{
//
}
ログイン同様、管理ユーザー用の動作を定義しておきます。adminValidator()
では、メールアドレスの重複チェックをadmins
テーブルで実施するように指定しています。
認証機能のための画面を作成
uiの導入で resources/views/auth
に関連画面のBladeができたので、一般ログインだけでなく管理ログインにも対応させていきます。
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Login') }}</div>
<div class="card-body">
@isset($authgroup)
<form method="POST" action="{{ url("login/$authgroup") }}">
@else
<form method="POST" action="{{ route('login') }}">
@endisset
@csrf
authgroup
パラメータがある場合、ログイン画面のタイトル表示を変更し、またログインに使う action URL を変更しています。これにより、一般ログインは /login
、管理ログインは /login/admin
にPOSTで要求されることになります。
登録画面も同様に変更します。
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Register') }}</div>
<div class="card-body">
@isset($authgroup)
<form method="POST" action="{{ url("register/$authgroup") }}">
@else
<form method="POST" action="{{ route('register') }}">
@endisset
@csrf
これでコンテンツエリアは authgroup
に対応しましたが、ヘッダーエリアも対応させるためにレイアウトも編集します。
<!-- Right Side Of Navbar -->
<ul class="navbar-nav ml-auto">
<!-- Authentication Links -->
@if(!Auth::check() && (!isset($authgroup) || !Auth::guard($authgroup)->check()))
@if (Route::has('login'))
<li class="nav-item">
@isset($authgroup)
<a class="nav-link" href="{{ url("login/$authgroup") }}">{{ __('Login') }}</a>
@else
<a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
@endisset
</li>
@endif
@if (Route::has('register'))
@isset($authgroup)
@if (Route::has("$authgroup-register"))
<li class="nav-item">
<a class="nav-link" href="{{ route("$authgroup-register") }}">{{ __('Register') }}</a>
</li>
@endif
@else
@if (Route::has('register'))
<li class="nav-item">
<a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a>
</li>
@endif
@endisset
@endif
@else
<li class="nav-item dropdown">
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
@isset($authgroup)
{{ Auth::guard($authgroup)->user()->name }}
@else
{{ Auth::user()->name }}
@endisset
</a>
管理ユーザー用のログイン後画面作成
デフォルトの home.blade.php
をコピーして、 admin.blade.php
を作ります。
$ cp resources/views/home.blade.php resources/views/admin.blade.php
ちょっと書き換えて、管理者へのメッセージを入れましょう。
@extends('layouts.app', ['authgroup'=>'admin'])
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">管理者 {{ __('Dashboard') }}</div>
<div class="card-body">
@if (session('status'))
<div class="alert alert-success" role="alert">
{{ session('status') }}
</div>
@endif
You are logged in as 管理者!
</div>
</div>
</div>
</div>
</div>
@endsection
ここで@extends
するときに、この画面がadmin
ガードであることをパラメータで指定していますが、これはコントローラ無しでルートから直接このブレードを表示しようとしているため、ここでパラメータ指定しています。
ルートを作成
ルートファイルにはすでに Auth
に必要なルート設定はされていますので、その後ろに管理ユーザー用のルートを追加します。
Route::get('/', function () {
return view('welcome');
});
Auth::routes();
Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
// ここから追加
Route::get('/login/admin', [App\Http\Controllers\Auth\LoginController::class, 'showAdminLoginForm']);
Route::get('/register/admin', [App\Http\Controllers\Auth\RegisterController::class, 'showAdminRegisterForm']);
Route::post('/login/admin', [App\Http\Controllers\Auth\LoginController::class, 'adminLogin']);
Route::post('/register/admin', [App\Http\Controllers\Auth\RegisterController::class, 'registerAdmin'])->name('admin-register');
Route::view('/admin', 'admin')->middleware('auth:admin')->name('admin-home');
ログイン後のリダイレクト先設定
ログイン後、Laravelのデフォルトでは/home
にリダイレクトされますが、管理ユーザーは/admin
にリダイレクトさせるようにします。これには認証後リダイレクトのミドルウェアを編集します。
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if ($guard == "admin" && Auth::guard($guard)->check()) {
return redirect(route('admin-home'));
}
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
RedirectIfAuthenticated
ミドルウェアは、guards
パラメータを受け取るので、これを見て処理を判断しています。
認証例外発生時のリダイレクト先設定
ログインしていない状態で保護されたページを見ようとした場合、今のままではとにかく/login
にリダイレクトされます。これだと、管理画面にアクセスしようとしたときに一般ログイン画面に飛ばされてしまい、不便です。管理画面用のルートからは/login/admin
にリダイレクトするようにします。
use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
[...]
class Handler extends ExceptionHandler
{
[...]
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return response()->json(['message' => $exception->getMessage()], 401);
}
if ($request->is('admin') || $request->is('admin/*')) {
return redirect()->guest('/login/admin');
}
return redirect()->guest($exception->redirectTo() ?? route('login'));
}
動作確認
ここまでで、一般ユーザーの登録、ログイン、ホーム画面、ログアウト、管理ユーザーの登録、ログイン、ホーム画面、ログアウトができるようになりました。一度簡易サーバーを起動して動作を確認してみてください。
$ php artisan serve --port=80
残るはリセットパスワードです。
追加準備
.env
でメールが送信できるように設定しておきます。パスワードリセット用のメールがこの設定を使って送信されます。
MAIL_DRIVER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=--------@gmail.com
MAIL_PASSWORD=---------
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=--------@gmail.com
MAIL_FROM_NAME="ローカルテスト"
パスワードリセット設定を作る
config/auth.php
に管理ユーザー用のパスワードリセット設定を作ります。
'passwords' => [
[...]
'admins' => [
'provider' => 'admins',
'table' => 'password_resets',
'expire' => 60,
'throttle' => 60,
],
パスワードリセット関連のコントローラを追加する
ログインやユーザー登録と違って、パスワードリセットではコントローラのメンバ変数にガードの指定が必要など、1つのコントローラファイル内で処理が難しいので、アプローチを変えてコントローラ自体を別に作ることにします。
リセットリクエストコントローラの作成
パスワードリセットリクエスト画面を表示するためのコントローラであるResetPasswordController
をコピーします。
$ cp app/Http/Controllers/Auth/ResetPasswordController.php app/Http/Controllers/Auth/AdminResetPasswordController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
[...]
class AdminResetPasswordController extends Controller
{
[...]
protected $redirectTo = '/admin';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest:admin');
}
public function showResetForm(Request $request, $token = null)
{
return view('auth.passwords.reset')->with(
['token' => $token, 'email' => $request->email, 'authgroup' => 'admin']
);
}
protected function guard()
{
return Auth::guard('admin');
}
protected function broker()
{
return Password::broker('admins');
}
guard()
をオーバーライドしてadmin
ガードを指定し、broker()
をオーバーライドしてconfig/auth.php
に設定したパスワードリセット設定をブローカーに指定します。あとはログインコントローラのときと同様です。メソッド名ではなく、クラス名そのものが変わっていることに注意してください。
リセット実施コントローラの作成
同様に、パスワードリセット実施画面を表示するためのコントローラであるForgotPasswordController
をコピーし、broker()
をオーバーライドします。
$ cp app/Http/Controllers/Auth/ForgotPasswordController.php app/Http/Controllers/Auth/AdminForgotPasswordController.php
use Illuminate\Support\Facades\Password;
[...]
class AdminForgotPasswordController extends Controller
{
[...]
use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest:admin');
}
public function showLinkRequestForm()
{
return view('auth.passwords.email')->with(
['authgroup' => 'admin']
);
}
protected function broker()
{
return Password::broker('admins');
}
}
パスワードリセットリクエスト画面修正
パスワードリセットリクエスト画面でも、画面上の表示とフォームのaction
をガードによって変更するように修正します。
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Reset Password') }}</div>
<div class="card-body">
@if (session('status'))
<div class="alert alert-success" role="alert">
{{ session('status') }}
</div>
@endif
@isset($authgroup)
<form method="POST" action="{{ route("$authgroup.password.email") }}">
@else
<form method="POST" action="{{ route('password.email') }}">
@endisset
@csrf
パスワードリセット実施画面修正
これも同様に変更します。
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">{{ isset($authgroup) ? ucwords($authgroup) : ""}} {{ __('Reset Password') }}</div>
<div class="card-body">
@isset($authgroup)
<form method="POST" action="{{ route($authgroup.'.password.update') }}">
@else
<form method="POST" action="{{ route('password.update') }}">
@endisset
@csrf
ログイン画面修正
「パスワードを忘れた」のリンク先を制御するため、ログイン画面を修正します。
<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(isset($authgroup) ? $authgroup.'.password.request' : 'password.request'))
<a class="btn btn-link" href="{{ route(isset($authgroup) ? $authgroup.'.password.request' : 'password.request') }}">
{{ __('Forgot Your Password?') }}
</a>
@endif
</div>
</div>
リセットパスワードメールを作成
リセットパスワードのメールには、パスワードリセットのためのリンクボタンが含まれます。このリンクを変更するには、このリンクを含む管理ユーザー用のパスワードリセット通知を新しく作って、管理ユーザーのリセットの場合にはそれを使うように設定する必要があります。
管理ユーザー用パスワードリセット通知の作成
$ php artisan make:notification AdminResetPassword
実際の中身は、Illuminate\Auth\Notifications\ResetPassword
を継承して以下のように toMail()
と buildMailMessage()
を親からコピーしてきて少し改造します。
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Lang;
class AdminResetPassword extends Resetpassword
{
use Queueable;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
if (static::$toMailCallback) {
return call_user_func(static::$toMailCallback, $notifiable, $this->token);
}
if (static::$createUrlCallback) {
$url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
} else {
$url = url(route('admin.password.reset', [
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
], false));
}
return $this->buildMailMessage($url);
}
protected function buildMailMessage($url)
{
return (new MailMessage)
->subject(Lang::get('Reset Admin Password Notification'))
->line(Lang::get('You are receiving this email because we received a password reset request for your account.'))
->action(Lang::get('Reset Password'), $url)
->line(Lang::get('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.' . config('auth.defaults.passwords') . '.expire')]))
->line(Lang::get('If you did not request a password reset, no further action is required.'));
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}
管理ユーザー用パスワードリセット通知をモデルに設定
パスワードリセットに使われる通知は、Authenticatable
なユーザーモデルに設定されています。デフォルトではIlluminate\Auth\Notifications\ResetPassword
ですが、これをapp/Models/Admin
モデルでは上で作った通知に変更します。
use App\Notifications\AdminResetPassword;
[...]
class Admin extends Authenticatable
{
[...]
// Override default reset password
public function sendPasswordResetNotification($token)
{
$this->notify(new AdminResetPassword($token));
}
パスワードリセット用ルートを作成
ルートファイルに追記します。
Route::get('password/admin/reset', [App\Http\Controllers\Auth\AdminForgotPasswordController::class, 'showLinkRequestForm'])->name('admin.password.request');
Route::post('password/admin/email', [App\Http\Controllers\Auth\AdminForgotPasswordController::class, 'sendResetLinkEmail'])->name('admin.password.email');
Route::get('password/admin/reset/{token}', [App\Http\Controllers\Auth\AdminResetPasswordController::class, 'showResetForm'])->name('admin.password.reset');
Route::post('password/admin/reset', [App\Http\Controllers\Auth\AdminResetPasswordController::class, 'reset'])->name('admin.password.update');
以上です!お疲れ様でした。
※元ネタは Laravel 6版。