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

# はじめに

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

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

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

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

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

# 作成手順

本記事ではクライアントアプリを作成していきます。

## 7. トークンのリクエスト処理の追加

### Migration の作成

`users` テーブルから不要なカラム（ `email_verified_at`, `password`, `remember_token` ）を削除するため、 `make:migration` コマンドを実行してファイルを作成した後、以下の記述を追加します。また、`email` カラムに関しては一意制約を削除します。

```bash
php artisan make:migration drop_columns_from_users_table
```

```diff_php:YYYY_MM_DD_HHmmss_drop_columns_from_users_table.php
public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
-       //
+       $table->dropColumn('email_verified_at');
+       $table->dropColumn('password');
+       $table->dropColumn('remember_token');
+
+       $table->dropUnique(['email']);
    });
}

public function down(): void
{
    Schema::table('users', function (Blueprint $table) {
-       //
+       $table->timestamp('email_verified_at')->nullable()->after('email');
+       $table->string('password')->after('email_verified_at');
+       $table->rememberToken()->after('password');
+
+       $table->unique(['email']);
    });
}
```

続いて、`access_token` を保存するカラムを追加するため、同様に `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->string('access_token', 2048)->after('email');
    });
}

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

`migrate` コマンドを実行して、マイグレーションを実行します。

```bash
php artisan migrate
```

## Model の変更

存在しなくなったカラムに関する記述を Model から削除します。また、追加した `access_token` カラムに関する記述を Model に追加します。

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

protected $hidden = [
-   'password',
-   'remember_token',
+   'access_token',
];

- protected $casts = [
-   'email_verified_at' => 'datetime',
-   'password' => 'hashed',
- ];
```

### Controller の作成

`AuthController` を作成し、「OAuth サーバへのリダイレクト」と「OAuth サーバからのコールバック」の処理を記述します。

#### OAuth サーバへのリダイレクト

公式ドキュメントに記載されている処理を基に作成します。
処理はシンプルで、必要なパラメータを生成して OAuth サーバへリダイレクトしています。

https://laravel.com/docs/10.x/passport#code-grant-pkce-redirecting-for-authorization

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

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;

class AuthController extends Controller
{
    /**
     * OAuth サーバへリダイレクト
     *
     * @param Request $request
     * @return RedirectResponse
     */
    public function redirect(Request $request): RedirectResponse
    {
        $state = Str::random(40);
        $request->session()->put('state', $state);

        $codeVerifier = Str::random(128);
        $request->session()->put('code_verifier', $codeVerifier);

        $codeChallenge = strtr(rtrim(
            base64_encode(hash('sha256', $codeVerifier, true)),
            '='
        ), '+/', '-_');

        $query = http_build_query([
            'client_id' => env('LARAVELPASSPORT_CLIENT_ID'),
            'redirect_uri' => url('/auth/callback'),
            'response_type' => 'code',
            'scope' => '',
            'state' => $state,
            'code_challenge' => $codeChallenge,
            'code_challenge_method' => 'S256',
        ]);

        return redirect(env('LARAVELPASSPORT_HOST') . '/oauth/authorize?' . $query);
    }
```

#### OAuth サーバからのコールバック

公式ドキュメントに記載されている処理を基に作成します。処理の内容は以下の通りです。

https://laravel.com/docs/10.x/passport#code-grant-pkce-converting-authorization-codes-to-access-tokens

1. `state` が不正な値でないかチェックする。
2. OAuth サーバに認可コードを送信してアクセストークンを取得する。
3. 認証済みのユーザ情報を OAuth サーバから取得する。
4. ログインしたユーザのレコードを `users` テーブルに新規登録する。

```php:app\Http\Controllers\AuthController.php
    /**
     * OAuth サーバからのコールバック
     *
     * @param Request $request
     * @return RedirectResponse
     */
    public function callback(Request $request): RedirectResponse
    {
        $state = $request->session()->pull('state');
        if (strlen($state) <= 0 || $state !== $request->state) {
            throw new \InvalidArgumentException('Invalid state value.');
        }

        $codeVerifier = $request->session()->pull('code_verifier');
        $accessToken = $this->getToken($codeVerifier, $request->code);

        $response = $this->authorizedRequest($accessToken)
            ->get(env('LARAVELPASSPORT_HOST') . '/api/user');

        $user = User::create([
            'email' => $response['email'],
            'name' => $response['name'],
            'access_token' => $accessToken,
        ]);

        Auth::login($user);

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

        return redirect('/');
    }

    /**
     * OAuth サーバからアクセストークンを取得
     *
     * @param string $codeVerifier
     * @param string $code
     * @return string
     */
    private function getToken(string $codeVerifier, string $code): string
    {
        $response = Http::asForm()->post(env('LARAVELPASSPORT_HOST') . '/oauth/token', [
            'grant_type' => 'authorization_code',
            'client_id' => env('LARAVELPASSPORT_CLIENT_ID'),
            'redirect_uri' => url('/auth/callback'),
            'code_verifier' => $codeVerifier,
            'code' => $code,
        ]);

        if ($response->status() !== 200) {
            throw new HttpException($response->status());
        }
        return $response['access_token'];
    }

    /**
     * 認証済みリクエストの送信（ヘッダの付加）
     *
     * @param string $accessToken
     * @return PendingRequest
     */
    private function authorizedRequest(string $accessToken): PendingRequest
    {
        return Http::withHeaders([
            'Accept' => 'application/json',
            'Authorization' => 'Bearer ' . $accessToken,
        ]);
    }
}
```

Laravel Passport に関する OAuth サーバの環境変数を `.env` に追記します。

```
LARAVELPASSPORT_CLIENT_ID=[OAuth2Server Authorization Code Grant の Client ID]
LARAVELPASSPORT_HOST=http://localhost:8080
```

### ルートの登録

追加した Controller にアクセスするルートを登録します。

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

## 8. Laravel Sanctum の初期設定

クライアントアプリでは Laravel Sanctum を使用して認証管理を行います。
そこで、Sanctum の SPA 認証を有効にするため、`EnsureFrontendRequestsAreStateful` ミドルウェアを `api` グループに追加します。

```diff_php:app\Http\Kernel.php
        'api' => [
-           // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
+           \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];
```

また、SPA がリクエストを行うドメインを `.env` に設定します。

```
SANCTUM_STATEFUL_DOMAINS=localhost:8000
```

## 9. ログインユーザ確認画面の作成

### Tailwind CSS ／ Vue.js ／ Vue Router のインストール

リソースサーバ 兼 認証サーバにインストールした手順と同じであるため、省略させて頂きます。こちらの記事をご参照ください。

https://qiita.com

### View の作成

ログインユーザ確認画面として表示する `Index.vue` を新たに作成します。初期表示時にユーザ情報を取得する API を実行しています。また認証有無によって、ログイン／ログアウトボタンの表示を切り替えています。

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

const user = await axios
  .get('/api/user')
  .then((response) => response?.data)
  .catch(() => null)
</script>

<template>
  <div class="flex justify-center">
    <div class="max-w-md w-full rounded overflow-hidden shadow-lg my-6 mx-4 md:mx-auto">
      <div class="font-bold text-xl px-4 py-6">ログインユーザ確認</div>
      <template v-if="user !== null">
        <div class="border-y border-gray-100">
          <dl class="divide-y divide-gray-100">
            <div class="px-4 py-6 sm:grid sm:grid-cols-3">
              <dt class="text-sm font-medium leading-6 text-gray-900">ユーザ名</dt>
              <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
                {{ user.name }}
              </dd>
            </div>
            <div class="px-4 py-6 sm:grid sm:grid-cols-3">
              <dt class="text-sm font-medium leading-6 text-gray-900">メールアドレス</dt>
              <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
                {{ user.email }}
              </dd>
            </div>
          </dl>
        </div>
      </template>

      <div class="flex justify-center my-4">
        <a
          v-if="user !== null"
          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>
        <a
          v-else
          href="/auth/redirect"
          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>
      </div>
    </div>
  </div>
</template>

```

### ルートの登録

追加した View を表示するルートを登録します。

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

```diff_php:routes\web.php
Route::get('/auth/redirect', [\App\Http\Controllers\AuthController::class, 'redirect'])->name('login');
Route::get('/auth/callback', [\App\Http\Controllers\AuthController::class, 'callback']);

+ Route::get('/', fn () => view('app'));
```

## 10. ログアウト処理の追加

### Controller の作成

ログアウト処理を `AuthController` に追加します。処理の内容は以下の通りです。

1. OAuth サーバのアクセストークン／リフレッシュトークンを取り消すため、トークン取消 APIへリクエストを送信する。（トークン取消 API は後ほど作成します） 
2. クライアントアプリからログアウトする。
3. OAuth サーバにおいてもログアウトするため、OAuth サーバの `/logout` ルートへリダイレクトする。また、OAuth サーバでログアウトした後にクライアントアプリへ戻ってきた際の URL をパラメータとして付加しておく。

```diff_php:app\Http\Controllers\AuthController.php
+   public function logout(Request $request): RedirectResponse
+   {
+       /** @var User ログインユーザ */
+       $user = Auth::user();
+       $user = $user->makeVisible(['access_token']);
+
+       $url = env('LARAVELPASSPORT_HOST') . '/api/token/' . env('LARAVELPASSPORT_CLIENT_ID');
+       $this->authorizedRequest($user->access_token)->delete($url);
+
+       Auth::logout();
+       
+       $request->session()->invalidate();
+       $request->session()->regenerateToken();
+
+       $query = http_build_query(['redirect_uri' => url('/')]);
+       return redirect(env('LARAVELPASSPORT_HOST') . '/logout?' . $query);
+   }
```

### ルートの登録

追加したログアウト処理を実行するルートを登録します。

```diff_php:routes\web.php
+ Route::middleware('auth')->group(function () {
+     Route::get('/logout', [\App\Http\Controllers\AuthController::class, 'logout']);
+ });
```

# おわりに

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

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