10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-18

はじめに

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

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

【リソースサーバ 兼 認証サーバ】
1. Laravel Passport のインストール
2. Laravel Passport の初期設定
3. ログイン画面の作成
4. マイページ画面の作成
5. ユーザ情報確認/更新画面の作成
6. ログアウト処理の追加

【クライアントアプリ】
7. トークンのリクエスト処理の追加
8. Laravel Sanctum の初期設定
9. ログインユーザ情報表示画面の作成
10. ログアウト処理の追加

【リソースサーバ 兼 認証サーバ】
11. Laravel Passport のルート登録
12. トークン取消 API の作成

作成手順

1. Laravel Passport のインストール 

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

composer.json
"require": {
    "php": "^8.1",
    "guzzlehttp/guzzle": "^7.2",
    "laravel/framework": "^10.10",
-   "laravel/sanctum": "^3.2",
    "laravel/tinker": "^2.8"
},
cors.php
-   'paths' => ['api/*', 'sanctum/csrf-cookie'],
+   'paths' => ['api/*'],
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月時点の最新バージョンを指定)

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

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

composer update

2. Laravel Passport の初期設定

Laravel Passport の公式ドキュメント に記載されている内容を基に設定を行います。

データベースの準備

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

php artisan migrate

クライアントの作成

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

php artisan passport:install --uuids

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

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 は後ほど使用するため、保存しておいてください

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 トレイトを追記します。

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

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

Passport の認証で用いる api ガードを作成するため、auth.php に以下の内容を追記します。

config\auth.php
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
+       'api' => [
+           'driver' => 'passport',
+           'provider' => 'users',
+       ],
    ],

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

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 にルートを無視する設定を追記します。後ほど、必要に応じてルートを追加したいと思います。

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

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

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

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 に以下の記述を追加します。

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

有効期限の設定

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

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 コマンドを実行してファイルを作成した後、以下の記述を追加します。

php artisan make:migration add_columns_to_users_table
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 カラムを追加します。

php artisan migrate

Model の変更

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

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. emailpassword の入力値をチェックする。
  2. emailpassword を用いて認証を試みる。
    2-1. 認証成功であれば、最終ログイン日時を更新してリダイレクトする。
    2-2. 認証失敗であれば、エラーメッセージを返却する。
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 をインストールします。以下のコマンドを実行して、インストールおよび設定ファイルの作成を行います。

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

対象のファイルにスタイルを適用するため、tailwind.config.js を変更します。

tailwind.config.js
export default {
- content: [],
+ content: ['./resources/**/*.{js,blade.php}'],

app.css ファイルに Tailwind CSS の記述を追加します。

resources\css\app.css
@tailwind base;
@tailwind components;
@tailwind utilities;

View の作成

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

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 にアクセスするルートをそれぞれ登録します。

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 をインストールします。

npm install @vitejs/plugin-vue --save-dev

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

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 を新規作成します。

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> を用いてレンダリングを制御します。

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.jscreateApp() の記述を追加して、Vueアプリケーションのインスタンスを作成します。

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 を変更します。

tailwind.config.js
export default {
- content: ['./resources/**/*.{js,blade.php}'],
+ content: ['./resources/**/*.{vue,js,blade.php}'],

Vue Router のインストール

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

npm install vue-router@4

Vue Router のインスタンスを作成する index.js を新規作成します。

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 に以下の記述を追加します。

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 を用いて認証の有無を判定してくれます。

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 に以下の記述を追加します。(名前は任意で設定)

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 を実行しています。未認証であればログイン画面にリダイレクトします。

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 を表示する ルートを追加します。また、未認証/認証済みでルートを制御するため、ミドルウェアを適用します。

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'));
+ });
resources\js\router\index.js
- routes: []
+ routes: [
+   {
+     path: '/',
+     name: 'index',
+     component: () => import('../views/Index.vue')
+   }
+ ]

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

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

動作確認

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

npm run build
php artisan serve

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

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

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',
+       ]);
}
php artisan db:seed

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

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

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

おわりに

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

10
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?