😄

Laravelで簡易的な管理者機能を作る

2024/03/19に公開

目標

LaravelでAPIサーバーを作り、アカウント登録・ログイン・ログアウトなどのユーザー認証機能、管理者によるアカウントBAN、削除機能を構築する。

リポジトリ

今回のリポジトリ

主な機能

  • ユーザーアカウント登録・Eメール認証
  • ログイン
  • ログアウト
  • アカウント情報取得
  • 通知一覧の取得
  • 通知の既読
  • 管理者UIへのアクセス・アクセス制限
  • ユーザーアカウントの削除・停止

※バックエンドのみ記事に書くので、UI部分はレポジトリを参照してください。

環境構築

  • 使用フレームワーク・ライブラリ・パッケージ
  1. Next.js
  2. Laravel
  3. spatie/laravel-permission
  4. larave/fortify
  5. axios
  6. @radix-ui/themes

事前準備

  1. データベース接続
    今回はSQLiteを使って接続します。

Laravel Fortifyの設定

Laravel Fortifyは、Laravelのフロントエンドにとらわれない認証バックエンドの実装です123。Fortifyは、ログイン、ユーザー登録、パスワードのリセット、メールの検証など、Laravelの認証機能をすべて実装するために必要なルートとコントローラを登録します

公式ドキュメントより

主な機能

  • ログイン機能(Rate Limiting機能付き)
  • ユーザー登録機能
  • パスワードリセット機能
  • メール認証機能
  • プロフィール情報の更新機能
  • パスワードの更新機能
  • 2段階認証機能

バックエンドのみ機能が提供される。オプションを設定すれば、viewも表示される

インストール

composer require laravel/fortify

サービスプロバイダへの登録

config/app.php

'providers' => [
    // 略
    App\Providers\FortifyServiceProvider::class, 
]

コマンド実行

php artisan migrate

configファイルの設定

今回はバックエンドで使用する想定なので、以下のように設定ファイルを編集します

config/fortify.php

// ルーティングのprefix
'prefix' => 'api',
// ビューのルーティングを無効化
'views' => false,
// 各認証機能
'features' => [
  Features::registration(), 
  Features::resetPasswords(), 
  Features::emailVerification(),
  // Features::updateProfileInformation(), 
  // Features::updatePasswords(),
  // Features::twoFactorAuthentication([
      // 'confirm' => true,
      // 'confirmPassword' => true,
      // 'window' => 0,
  // ]),
]

モデルの変更

もしEメール認証機能を使用する場合、Userモデルを編集しないと確認メールが届きません。
メールの確認が必要ない場合、編集の必要はありません。

app/models/user.php

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements MustVerifyEmail {
  // ~
}

Laravel Permissionの設定

UserとRoleを紐づけるための中間テーブルを管理するライブラリ

RBAC方式のアクセス制御を実現する

AWSのIAMのイメージ

  • ユーザーに役割を当てる
  • ユーザーが特定の役割を持っているか確認
  • 役割に基づいたアクセス制御
  • 権限の管理

今回は簡易的ということで、2つのroleを設定します。通常ユーザーはroleを付与しません

role 説明
admin 管理者
banned アカウント停止ユーザー

インストール

composer require spatie/laravel-permission

サービスプロバイダへの登録

自動で登録されていない場合、記述してください

config/app.php

'providers' => [
    // 略
    Spatie\Permission\PermissionServiceProvider::class,
];

Kernel.phpへの登録

Kernel.php

protected $routeMiddleware = [
    'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
    'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
    'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
];

コマンド実行

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider

php artisan config:clear

php artisan migrate

モデルの変更

roleをUserモデルに紐づけます

App/Models/User.php

use Spatie\Permission\Traits\HasRoles; 

class User extends Authenticatable
{
    use HasRoles; // 追加
    // ...
}

Laravel本体に関する設定

認証状態の場合のレスポンスをJSONに変更

ログイン状態の時、HomeのURIリダイレクトしてしまうので、JSONレスポンスが返ってくるように編集します。

RedirectIfAuthenticated.php

use Illuminate\Http\JsonResponse;

class RedirectIfAuthenticated 
{
  public function handle(Request $request, Closure $next, string ...$guards): Response
    {
        $guards = empty($guards) ? [null] : $guards;

        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                // return redirect(RouteServiceProvider::HOME);
                $is_banned = User::find(Auth::id())->getRoleNames()->contains('banned');

                return response()->json([
                    'login' => true,
                    'is_banned' => $is_banned,
                ], 200);
            }
        }

        return $next($request);
    }
}

FortifyServiceProvider.php

registerメソッドを以下のように編集します

public function register(): void
    {
        $this->app->instance(LoginResponse::class, new class implements LoginResponse
        {
            public function toResponse($request)
            {
                $is_banned = Auth::user()->getRoleNames()->contains('banned');

                if ($is_banned) {
                    Auth::logout();

                    $request->session()->invalidate();
                    $request->session()->regenerateToken();

                    return response()->json([
                        'is_banned' => $is_banned,
                    ], 403);
                }

                return response()->json([
                    'login' => true,
                    'is_banned' => $is_banned,
                ]);
            }
        });

        $this->app->instance(LogoutResponse::class, new class implements LogoutResponse
        {
            public function toResponse($request)
            {
                return response()->json([
                    'code' => 200,
                    'user' => $request->user(),
                ]);
            }
        });
    }

通知クラスの作成

Webサービスには運営からの通知を受け取る機能がよくあります。なのでそれを疑似的に作ろうと思います。

Middlewareの作成

ユーザーがアカウント停止状態の時、403エラーとJSONレスポンスを返すmiddleware "CheckBanned"を作成する

public function handle(Request $request, Closure $next): Response
  {
      if (Auth::check()) {
          if (Auth::user()->getRoleNames()->contains('banned')) {
              Auth::logout();

              $request->session()->invalidate();
              $request->session()->regenerateToken();

              return response()->json([
                  'banned' => true,
              ], 403);
          } else {
              return $next($request);
          }
      }

      return response()->json([
          'login' => false,
      ], 401);
  }

Middlewareの登録

kernel.php

protected $middlewareAliases = [
    'is_banned' => \App\Http\Middleware\CheckBanned::class,
];

Seederファイルの編集

DatabaseSeeder.php

<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use App\Notifications\TestNotification;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        $i = 1;
        // \App\Models\User::factory(10)->create();

        \App\Models\User::factory()->create([
            'name' => 'test',
            'email' => 'test@test.com',
            'password' => 'password',
        ]);
        \App\Models\User::factory()->create([
            'name' => 'test1',
            'email' => 'test1@test.com',
            'password' => 'password',
        ]);
        \App\Models\User::factory(2)->create();

        $user = \App\Models\User::find(1);
        while ($i <= 20) {
            $user->notify(new TestNotification(\App\Models\User::find(2)));
            $i++;
        }

        $admin = Role::create(['name' => 'admin']);

        $permissionOfDeleteAccount = Permission::create(['name' => 'delete account']);

        $admin->givePermissionTo($permissionOfDeleteAccount);

        $user->assignRole('admin');

        $banned = Role::create(['name' => 'banned']);
    }
}

APIルートの定義

api.phpを編集します。

<?php

use Illuminate\Http\Request;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth:sanctum', 'is_banned'])->get('/user', function (Request $request) {
    return response()->json([
        'user' => auth()->user(),
        'notifications' => $request->user()->unreadNotifications,
        'role' => auth()->user()->getRoleNames(),
    ]);
});

Route::middleware('role:admin')->get('/users', function (Request $request) {
    $data = \App\Models\User::where('id', '!=', Auth::id())->get();
    $users = [];

    foreach ($data as $user) {
        array_push($users, [
            'user' => $user,
            'role' => \App\Models\User::find($user->id)->getRoleNames(),
        ]);
    }

    return response()->json($users);
});

Route::middleware('role:admin')->post('/users/{id}/ban', function (Request $request) {
    $user = \App\Models\User::find($request->id);

    $user->assignRole('banned');

    return response()->json([
        'banned' => true,
    ]);
});

Route::middleware('role:admin')->delete('/users/{id}', function (Request $request) {
    \App\Models\User::where('id', $request->id)->delete();

    return response()->json([
        'delete' => true,
    ]);
});

Route::post('/notifications/{notification}/read', function (DatabaseNotification $notification) {
    $notification->markAsRead();

    return response()->json([
        'read' => true,
    ]);
});

Route::post('/notifications/read-all', function (Request $request) {
    auth()->user()->unreadNotifications->markAsRead();

    return response()->json([
        'read' => true,
    ]);
});

UI

詳しくはgithubのリポジトリを参照してください。

各画面の写真

  1. トップ画面(未ログイン時)
    トップ画面

  2. ログイン画面
    ログイン画面

  3. ログイン画面(アカウント停止状態のユーザーがログインした場合)
    アカウント停止のユーザーがログインした場合

  4. サインアップ画面
    サインアップ画面

  5. 確認メール
    確認メール

  6. ログイン後のトップ画面(adminロール)
    adminロールユーザーのログイン後

  7. ログイン後のトップ画面(adminではないユーザー)
    ログイン後のトップ画面(adminではないユーザー)

  8. 通知ドロップダウン
    通知ドロップダウン

  9. 通知の詳細情報のダイアログ
    通知の詳細情報のダイアログ

  10. 通知の一括既読したあと
    通知の一括既読したあと

  11. adminダッシュボード
    adminダッシュボード

  12. adminダッシュボード(ユーザーをアカウント停止状態にした後)
    ユーザーをアカウント停止状態にした後

参考

各公式ドキュメント

Laravel Permissionを使用して簡単に権限を付与する

Laravel9でFortifyを使用する

Laravel FortifyのレスポンスをJSONで返す方法

エラーが発生したら、コメントで書いてください。

GitHubで編集を提案

Discussion