Laravel PassportでOAuth2を用いた認証機能を作成しよう！①

# はじめに

本記事は Laravel Passport で OAuth2 を用いた認証機能を作成する手順の一部です。
事前にこちらの記事からご覧ください。

https://qiita.com/falya128/items/652a0d150faaaeb58b2b

※ 太字の部分が本記事で説明している内容です。

【リソースサーバ 兼 認証サーバ】
    **1. Laravel Passport のインストール**
    **2. Laravel Passport の初期設定**
    **3. ログイン画面の作成**
    **4. マイページ画面の作成**
    [5. ユーザ情報確認／更新画面の作成](https://qiita.com/falya128/items/ef8885b4fd902a05d8bc)
    [6. ログアウト処理の追加](https://qiita.com/falya128/items/ef8885b4fd902a05d8bc)
 
【クライアントアプリ】
    [7. トークンのリクエスト処理の追加](https://qiita.com/falya128/items/651daf3f4dd91ac70dcc)
    [8. Laravel Sanctum の初期設定](https://qiita.com/falya128/items/651daf3f4dd91ac70dcc)
    [9. ログインユーザ情報表示画面の作成](https://qiita.com/falya128/items/651daf3f4dd91ac70dcc)
    [10. ログアウト処理の追加](https://qiita.com/falya128/items/651daf3f4dd91ac70dcc)

【リソースサーバ 兼 認証サーバ】
    [11. Laravel Passport のルート登録](https://qiita.com/falya128/items/b73d91b0bde1fa65ad98)
    [12. トークン取消 API の作成](https://qiita.com/falya128/items/b73d91b0bde1fa65ad98)

# 作成手順

## 1. Laravel Passport のインストール　

Laravel Passport を用いるため、不要となる Laravel Sanctum をアンインストールします。関連するファイルから Sanctum に関する記述を削除します。また、`config\sanctum.php` はファイルごと削除します。

```diff_php:composer.json
"require": {
    "php": "^8.1",
    "guzzlehttp/guzzle": "^7.2",
    "laravel/framework": "^10.10",
-   "laravel/sanctum": "^3.2",
    "laravel/tinker": "^2.8"
},
```

```diff_php:cors.php
-   'paths' => ['api/*', 'sanctum/csrf-cookie'],
+   'paths' => ['api/*'],
```

```diff_php:app\Models\User.php
- use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
-   use HasApiTokens, HasFactory, Notifiable;
+   use HasFactory, Notifiable;
```

Sanctum に関する記述を削除したら、`composer.json` に `"laravel/passport": "^11.8"` の記述を追加します。（2023年8月時点の最新バージョンを指定）

```diff_php:composer.json
"require": {
    "php": "^8.1",
    "guzzlehttp/guzzle": "^7.2",
    "laravel/framework": "^10.10",
+   "laravel/passport": "^11.8",
    "laravel/tinker": "^2.8"
},
```

以下のコマンドを実行してパッケージを更新すると、Sanctum が削除されて Passport がインストールされます。

```bash
composer update
```

## 2. Laravel Passport の初期設定

[Laravel Passport の公式ドキュメント](https://laravel.com/docs/10.x/passport) に記載されている内容を基に設定を行います。

### データベースの準備

データベースのテーブルを作成するため、`migrate` コマンドを実行します。

```bash
php artisan migrate
```

### クライアントの作成

Passport の設定ファイルなどを作成するため、`passport:install` コマンドを実行します。実行すると Personal access client と Password grant client が作成されますが、今回は使用しませんので ID や Secret を保存しておく必要はありません。また、Laravel Passport のデフォルトではクライアントに連番の ID が割り振られますが、今回は ID として UUID を用いる方式を採用するため、`--uuids` オプションを指定してコマンドを実行します。

```bash
php artisan passport:install --uuids
```

途中でマイグレーションを再実行してもよいかと尋ねられたら `yes` と入力します。

```bash
In order to finish configuring client UUIDs, we need to rebuild the Passport database tables. Would you like to rollback and re-run your last migration? (yes/no) [no]:
 > yes
```

次に、実際に使用する Authorization Code Grant のクライアントを作成します。今回のクライアントアプリはパブリックな環境ではありませんが、認可コード横取り攻撃のリスクを低減するため、PKCE を用いた Authorization Code Grant で認証を行いたいと思います。そこで、クライアントを作成するコマンド `passport:client` に `--public` オプションを指定して実行します。**Client ID は後ほど使用するため、保存しておいてください**。


```bash
php artisan passport:client --public

 Which user ID should the client be assigned to? (Optional):
 > [入力なしで Enterキー押下]

 What should we name the client?:
 > OAuth2Server Authorization Code Grant

 Where should we redirect the request after authorization? [http://localhost/auth/callback]:
 > http://localhost:8000/auth/callback

New client created successfully.
Client ID: ********-****-****-****-************
Client secret:
```

### 認証に関する設定

認証で用いる User モデルに `HasApiTokens` トレイトを追記します。

```diff_php:app\Models\User.php
+ use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
-   use HasFactory, Notifiable;
+   use HasApiTokens, HasFactory, Notifiable;
```

Passport の認証で用いる `api` ガードを作成するため、`auth.php` に以下の内容を追記します。
```diff_php:config\auth.php
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
+       'api' => [
+           'driver' => 'passport',
+           'provider' => 'users',
+       ],
    ],
```

`api.php` で用いる Guard を `sanctum` から `api` に変更します。

```diff_php:routes\api.php
- Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
+ Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});
```

### 追加されたルートの整理

Laravel Passport ではデフォルトで以下のルートが追加されます。
```
  GET|HEAD  oauth/authorize .................. passport.authorizations.authorize › Laravel\Passport › AuthorizationController@authorize  
  POST      oauth/authorize ............... passport.authorizations.approve › Laravel\Passport › ApproveAuthorizationController@approve  
  DELETE    oauth/authorize ........................ passport.authorizations.deny › Laravel\Passport › DenyAuthorizationController@deny  
  GET|HEAD  oauth/clients ........................................ passport.clients.index › Laravel\Passport › ClientController@forUser  
  POST      oauth/clients .......................................... passport.clients.store › Laravel\Passport › ClientController@store  
  PUT       oauth/clients/{client_id} ............................ passport.clients.update › Laravel\Passport › ClientController@update  
  DELETE    oauth/clients/{client_id} .......................... passport.clients.destroy › Laravel\Passport › ClientController@destroy  
  GET|HEAD  oauth/personal-access-tokens .... passport.personal.tokens.index › Laravel\Passport › PersonalAccessTokenController@forUser  
  POST      oauth/personal-access-tokens ...... passport.personal.tokens.store › Laravel\Passport › PersonalAccessTokenController@store  
  DELETE    oauth/personal-access-tokens/{token_id} passport.personal.tokens.destroy › Laravel\Passport › PersonalAccessTokenControlle…  
  GET|HEAD  oauth/scopes ............................................... passport.scopes.index › Laravel\Passport › ScopeController@all  
  POST      oauth/token .......................................... passport.token › Laravel\Passport › AccessTokenController@issueToken  
  POST      oauth/token/refresh .......................... passport.token.refresh › Laravel\Passport › TransientTokenController@refresh  
  GET|HEAD  oauth/tokens ........................... passport.tokens.index › Laravel\Passport › AuthorizedAccessTokenController@forUser  
  DELETE    oauth/tokens/{token_id} .............. passport.tokens.destroy › Laravel\Passport › AuthorizedAccessTokenController@destroy
```

各ルートがどのような用途で使用されるかを把握し、正しく管理できるなら問題ありませんが、**なんとなくで残しておくのは悪用される恐れがあるため、避けた方が良いと考えます。**
そこで今回は必要なルートのみを登録するため、`AppServiceProvider` にルートを無視する設定を追記します。後ほど、必要に応じてルートを追加したいと思います。

```diff_php:app\Providers\AppServiceProvider.php
public function register(): void
{
+   Passport::ignoreRoutes();
}
```

### 認可画面をスキップする設定

今回は認証の用途で使用するため、認可の画面をスキップする（表示させない）設定を行います。設定を行うためには Client クラスをオーバーライドする必要があるので、新たに `PassportClient` クラスを作成します。

```php:app\Clients\PassportClient.php
<?php

namespace App\Clients;

use Laravel\Passport\Client;

class PassportClient extends Client
{
    public function skipsAuthorization(): bool
    {
        return true;
    }
}
```

作成した `PassportClient` クラスを Laravel Passport で用いるため、`AuthServiceProvider` に以下の記述を追加します。

```diff_php:app\Providers\AuthServiceProvider.php
public function boot(): void
{
+   Passport::useClientModel(PassportClient::class);
}
```

### 有効期限の設定

トークンの有効期限がデフォルトでは1年後となっており非常に長いため、今回は短めの1日後に設定をします。また、リフレッシュトークンは使用しない想定のため、リフレッシュトークンにも同じ有効期限を設定します。

```diff_php:app\Providers\AuthServiceProvider.php
public function boot(): void
{
    Passport::useClientModel(PassportClient::class);
+   Passport::tokensExpireIn(now()->addDay());
+   Passport::refreshTokensExpireIn(now()->addDay());
}
```

## 3. ログイン画面の作成

## Migration の作成

`users` テーブルに最終ログイン日時を保存したいため、カラムを追加します。`make:migration` コマンドを実行してファイルを作成した後、以下の記述を追加します。

```bash
php artisan make:migration add_columns_to_users_table
```

```diff_php:YYYY_MM_DD_HHmmss_add_columns_to_users_table.php
public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
-       //
+       $table->timestamp('last_login_at')->nullable()->after('remember_token');
    });
}

public function down(): void
{
    Schema::table('users', function (Blueprint $table) {
-       //
+       $table->dropColumn('last_login_at');
    });
}
```

`migrate` コマンドを実行して、`users` テーブルに `last_login_at` カラムを追加します。

```bash
php artisan migrate
```

## Model の変更

追加した `last_login_at` カラムに関する記述を Model に追加します。

```diff_php:app\Models\User.php
protected $fillable = [
    'name',
    'email',
    'password',
+   'last_login_at',
];

protected $casts = [
    'email_verified_at' => 'datetime',
    'password' => 'hashed',
+   'last_login_at' => 'datetime:Y-m-d H:i:s',
];
```

### Controller の作成

ログイン処理を実行する `AuthController` を作成します。ログイン処理の内容は以下の通りです。

1. `email` と `password` の入力値をチェックする。
2. `email` と `password` を用いて認証を試みる。
    2-1. 認証成功であれば、最終ログイン日時を更新してリダイレクトする。
    2-2. 認証失敗であれば、エラーメッセージを返却する。

```php:app\Http\Controllers\AuthController.php
<?php

namespace App\Http\Controllers;

use App\Providers\RouteServiceProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    /**
     * ログイン
     *
     * @param Request $request
     * @return RedirectResponse
     */
    public function login(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            /** @var User ログインユーザ */
            $user = Auth::user();

            $user->update(['last_login_at' => Carbon::now()]);

            $request->session()->regenerate();

            return redirect()->intended(RouteServiceProvider::HOME);
        }

        return back()
            ->withErrors(['email' => '認証に失敗しました。'])
            ->onlyInput('email');
    }
}
```

### Tailwind CSS のインストール
フロントエンドの見た目を良くするため、Tailwind CSS をインストールします。以下のコマンドを実行して、インストールおよび設定ファイルの作成を行います。

```bash
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```

対象のファイルにスタイルを適用するため、`tailwind.config.js` を変更します。
```diff_javascript:tailwind.config.js
export default {
- content: [],
+ content: ['./resources/**/*.{js,blade.php}'],
```

`app.css` ファイルに Tailwind CSS の記述を追加します。
```css:resources\css\app.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

### View の作成

ログイン画面として表示する `login.blade.php` を新たに作成します。

```html:resources\views\login.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ config('app.name') }}</title>
    @vite(['resources/css/app.css'])
  </head>

  <body>
    <div class="flex flex-col p-8">
      <h2 class="mt-4 text-center text-3xl font-bold">ログイン</h2>

      <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
        <form action="/login" method="POST">
          @csrf
          <div class="mt-4">
            <label for="email" class="text-sm font-medium">メールアドレス</label>
            <div class="mt-2">
              <input
                id="email"
                name="email"
                type="email"
                value="{{ old('email') }}"
                autocomplete="email"
                required
                placeholder="test@example.com"
                class="w-full rounded-md border-0 p-2 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 sm:leading-6"
              />
              @error('email')
              <div
                class="bg-red-100 border border-red-400 text-red-700 px-2 py-1 mt-1 text-sm rounded relative"
                role="alert"
              >
                {{ $message }}
              </div>
              @enderror
            </div>
          </div>

          <div class="mt-6">
            <label for="password" class="text-sm font-medium"> パスワード </label>
            <div class="mt-2">
              <input
                id="password"
                name="password"
                type="password"
                autocomplete="current-password"
                required
                class="w-full rounded-md border-0 p-2 ring-1 ring-inset ring-gray-300 sm:leading-6"
              />
              @error('password')
              <div
                class="bg-red-100 border border-red-400 text-red-700 px-2 py-1 mt-1 text-sm rounded relative"
                role="alert"
              >
                {{ $message }}
              </div>
              @enderror
            </div>
          </div>

          <div class="flex justify-center mt-10">
            <button
              type="submit"
              class="flex w-full sm:w-1/2 justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
            >
              ログインする
            </button>
          </div>
        </form>
      </div>
    </div>
  </body>
</html>
```

### ルートの登録

作成した Controller と View にアクセスするルートをそれぞれ登録します。

```diff_php:routes\web.php
- Route::get('/', function () {
-   return view('welcome');
- });
+ Route::get('/login', fn () => view('login'))->name('login');
+ Route::post('/login', [App\Http\Controllers\AuthController::class, 'login']);
```

## 4. マイページ画面の作成

マイページはログイン直後に表示する画面です。
したがって、認証済みの場合のみ表示可能とするように作成していきます。

### Vue.js のインストール

Vite 環境で Vue を動作させるため、`@vitejs/plugin-vue` をインストールします。
```bash
npm install @vitejs/plugin-vue --save-dev
```

`vite.config.js` に Vue の設定を追記します。併せて、Base URL も変更しておきます。

```diff_javascript:vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
+ import vue from '@vitejs/plugin-vue';

export default defineConfig({
+   base: '/',
    plugins: [
+       vue(),
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});
```

共通のテンプレートとなる `resources\views\app.blade.php` を新規作成します。

```html:resources\views\app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ config('app.name') }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
  </head>

  <body>
    <div id="app"></div>
  </body>
</html>
```

Vue ファイルにおいても、共通で使用する `resources\js\App.vue` を新規作成します。後ほど `setup()` 内に非同期処理を記述するため、`<Suspense>` を用いてレンダリングを制御します。

```vue:resources\js\App.vue
<script setup>
import { RouterView } from 'vue-router'
</script>

<template>
  <RouterView v-slot="{ Component }">
    <Suspense>
      <div>
        <component :is="Component" />
      </div>
    </Suspense>
  </RouterView>
</template>
```

`resources\js\app.js` に `createApp()` の記述を追加して、Vueアプリケーションのインスタンスを作成します。

```diff_javascript:resources\js\app.js 
import './bootstrap'
+ import App from './App.vue'
+ import { createApp } from 'vue'

+ const app = createApp(App)
+ app.mount('#app')
```

Vue ファイルに Tailwind CSS を適用するため、`tailwind.config.js` を変更します。
```diff_javascript:tailwind.config.js
export default {
- content: ['./resources/**/*.{js,blade.php}'],
+ content: ['./resources/**/*.{vue,js,blade.php}'],
```

### Vue Router のインストール

フロントエンドのルートを担う役割として Vue Router を用いるため、`vue-router` をインストールします。

```bash
npm install vue-router@4
```

Vue Router のインスタンスを作成する `index.js` を新規作成します。
```javascript:resources\js\router\index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: []
})
export default router
```

作成したインスタンスを Vue に読み込ませるため、`app.js` に以下の記述を追加します。
```diff_javascript:resources\js\app.js
import './bootstrap'

import App from './App.vue'
import { createApp } from 'vue'
+ import router from './router'

const app = createApp(App)
+ app.use(router)
app.mount('#app')
```

### JavaScript から認証済みリクエストを送る設定

Laravel Passport を使用した状態でフロントエンド（JavaScript）から認証済みリクエストを送るためには、`CreateFreshApiToken` ミドルウェアの追加が必要です。このミドルウェアを追加すると、暗号化された JWT が含まれた Cookie がサーバから送られるため、以降はその Cookie を用いて認証の有無を判定してくれます。

```diff_php:app\Http\Kernel.php
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
+       \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
    ],
```

また、Cookie の名前がデフォルトで "laravel_token" となるため、名前の変更を行います。`AuthServiceProvider` に以下の記述を追加します。（名前は任意で設定）

```diff_php:app\Providers\AuthServiceProvider.php
public function boot(): void
{
    Passport::useClientModel(PassportClient::class);
    Passport::tokensExpireIn(now()->addDay());
    Passport::refreshTokensExpireIn(now()->addDay());
+   Passport::cookie(Str::lower(env('APP_NAME') . '_token'));
}
```

### View の作成

マイページ画面として表示する `Index.vue` を新たに作成します。
初期表示時にユーザ情報を取得する API を実行しています。未認証であればログイン画面にリダイレクトします。


```vue:resources\js\views\Index.vue
<script setup>
import axios from 'axios'

let name = ''
await axios
  .get('/api/user')
  .then((response) => {
    name = response?.data?.name
  })
  .catch((reason) => {
    if (reason?.response?.status === 401) {
      window.location.href = '/login'
    }
  })
</script>

<template>
  <div class="text-center px-6 py-24">
    <div class="text-4xl font-bold">ようこそ、<br class="sm:hidden" />{{ name }} さん</div>
    <div class="mt-16 flex items-center justify-center space-x-5">
      <a
        href="/logout"
        class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
      >
        ログアウト
      </a>
      <button
        type="button"
        class="rounded-md bg-gray-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
      >
        ユーザ情報確認
      </button>
    </div>
  </div>
</template>
```

### ルートの登録

追加した View を表示する ルートを追加します。また、未認証／認証済みでルートを制御するため、ミドルウェアを適用します。

```diff_php:routes\web.php
+ Route::middleware(['guest'])->group(function () {
    Route::get('/login', fn () => view('login'))->name('login');
    Route::post('/login', [App\Http\Controllers\AuthController::class, 'login']);
+ });

+ Route::middleware(['auth:web'])->group(function () {
+   Route::get('/', fn () => view('app'));
+ });
```

```diff_javascript:resources\js\router\index.js
- routes: []
+ routes: [
+   {
+     path: '/',
+     name: 'index',
+     component: () => import('../views/Index.vue')
+   }
+ ]
```

認証時にリダイレクトされる Laravel の `HOME` ルートを変更します。

```diff_php:app\Providers\RouteServiceProvider.php
class RouteServiceProvider extends ServiceProvider
{
-   public const HOME = '/home';
+   public const HOME = '/';
```

### 動作確認

ここまで正しく作成できているか確認のため、実際に開発用サーバを起動して動作させてみます。以下のコマンドを実行して、`http://localhost:8000` にアクセスします。
```bash 
npm run build
php artisan serve
```

アクセスすると、このようなログイン画面が表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/db8fd868-84a5-4e40-afd6-3909f155730f.png" width="350" />

ログインするユーザが存在しないので、Seeder を用いてユーザを登録します。`DatabaseSeeder` を変更してから `db:seed` コマンドを実行するとユーザを登録できます。

```diff_php:database\seeders\DatabaseSeeder.php
public function run(): void
{
    // \App\Models\User::factory(10)->create();

-   // \App\Models\User::factory()->create([
-   //     'name' => 'Test User',
-   //     'email' => 'test@example.com',
-   // ]);
+       \App\Models\User::factory()->create([
+           'name' => 'Test User',
+           'email' => 'test@example.com',
+       ]);
}
```

```bash
php artisan db:seed
```

ログイン画面に以下の情報を入力して「ログインする」をクリックします。

メールアドレス : test@example.com
パスワード　　 : password

以下のようなマイページ画面が表示されれば OK です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/63096faa-0f28-df12-a6ec-3925be974c07.png" width="350" />

# おわりに

続きはこちらの記事をご覧ください。

https://qiita.com/falya128/items/ef8885b4fd902a05d8bc