Laravel vue Sanctum認証の実装

Laravelではviwe部分をvue3で実装し、コントローラと連動するアプリを作成することができます。vue3で作成したアプリに認証を実装する場合、プロジェクト作成時にbreezeパッケージを選択すれば認証のフレームワークを簡単に利用できます。その際、vue3との接続にはineatiaを使用するようセッティングされます。

「コンポーネントの独立性を上げたい」や「従来に近いvueの開発を行いたい」などの事情でineatiaではなくweb apiを利用したいケースが有ります（ineatiaを使用していても部分的にapiを使用することも可能）。この場合（ineatia使用を避けたい、全てのサービスアクセスをapiとしたい）、breezeを利用することが難しくなります。

breezeを使用せずに認証機能を実装したい場合、多少のスクラッチは必要ですがSanctum認証を利用することができます。本稿ではapiを使用したケースでSanctumによるSPA(SinglePageAplication)認証を実装する方法を紹介します。

Sanctum認証についてはこちらの記事を参考にさせて頂きました。

https://qiita.com/ucan-lab/items/3e7045e49658763a9566

本記事は別の記事で紹介したアプリケーションに、認証を実装する方法の紹介となります。アプリケーション自体の説明は以下の記事を参照下さい。

https://qiita.com/BlueMoonAZ/items/120512ba7c88c728463c

認証を実装したアプリの動作イメージです。
<details><summary>GIF動画展開</summary>

![Laravel-Vite-Vue3-Google-Chrome-2023-09-01-22-30-05.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/abfd701c-1066-15ea-2924-156943503767.gif)

</details>

認証を実装したアプリのコードは以下になります。

https://github.com/BlueMoonAz/fruits/tree/auth_sanctum

## 環境

Laravel10
Windows11

本例ではアプリケーションとweb apiが同一サーバー（同一オリジン）で動作する前提です。
## SPA認証の実装

### Sanctum設定

Kernel.phpを編集し、Sanctumミドルウェアを有効化します。$middlewareGroups['api']に記述されるSanctumのコメントアウトを外します。

```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,
        ],

        'api' => [
            //コメントアウトを外す
            \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
```

.envファイルを編集し、セッションドライバにcookieを指定します。
また、アプリケーションが動作するアドレスとポートを指定します。

```.env
SESSION_DRIVER=cookie
SANCTUM_STATEFUL_DOMAINS=localhost:8000
```

### userテーブルのシーディング

userテーブルは「Laravel vue3 サンプル作成」で実施したマイグレーションで作成済みです。テストアカウントを作るためにシーディングを行います。DatabaseSeeder.phpファイルを編集し、コメントアウトを外してfactoryを有効化します。

```php:database/seeders/DatabaseSeeder.php
public function run(): void
    {
        //コメントアウトを外す
        \App\Models\User::factory(10)->create();

        $this->call([FruitsTableSeeder::class]);
    }
```
コマンドプロンプトで以下を実行します
```
php artisan db:seed
```
userテーブルにレコードができているか確認します。
FruitsTableSeederは再実行なのでエラーになりますが、無視してください。

### loginリクエスト作成
```php:app/Http/Requests/Auth/LoginRequest.php
<?php declare(strict_types=1);

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

final class LoginRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'min:6'],
        ];
    }
}
```
### login/logoutコントローラ作成
```php:app/Http/Controllers/Auth/LoginController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;

final class LoginController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private readonly AuthManager $auth,
    ) {
    }

    /**
     * @param LoginRequest $request
     * @return JsonResponse
     * @throws AuthenticationException
     */
    public function __invoke(LoginRequest $request): JsonResponse
    {
        $credentials = $request->only(['email', 'password']);

        if ($this->auth->guard()->attempt($credentials)) {
            $request->session()->regenerate();

            return new JsonResponse([
                'message' => 'Authenticated.',
                'status' => 200,
            ]);
        }

        return new JsonResponse([
            'message' => 'Unauthenticated.',
            'status' => 400,
        ]);

        //throw new AuthenticationException();
    }
}
```
```php:app/Http/Controllers/Auth/LogoutController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class LogoutController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private readonly AuthManager $auth,
    ) {
    }

    /**
     * @param Request $request
     * @return JsonResponse
     */
    public function __invoke(Request $request): JsonResponse
    {
        if ($this->auth->guard()->guest()) {
            return new JsonResponse([
                'message' => 'Already Unauthenticated.',
            ]);
        }

        $this->auth->guard()->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return new JsonResponse([
            'message' => 'Unauthenticated.',
        ]);
    }
}
```
ルーティングを以下定義します。
```php:routes/web.php
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;

Route::post('/login', LoginController::class)->name('login');
Route::post('/logout', LogoutController::class)->name('logout');

```

### loginコンポーネント作成
login画面のコンポーネントを作成します

```javascript:resources/js/Pages/Auth/Login.vue
<script setup>
import {ref} from 'vue'
import axios from 'axios'
import {ElNotification } from 'element-plus'

const email = ref('');
const password = ref('');

const login = () =>{

    axios.get('/sanctum/csrf-cookie')
        .then((res)=>{
            axios.post('/login', {
                        email: email.value,
                        password: password.value,
                    }).then((res)=>{
                        console.log('login response:'+res.data.message);
                        console.log('login status:'+res.data.status);
                        if(res.data.status==200){
                            dialogVisible.value=false;
                        }else{
                            ElNotification({
                                title: 'Error',
                                message: 'ログイン失敗しました',
                                type: 'error',
                            })
                        }
                    }).catch((err)=>{
                        console.log('login error:'+err);
                    })
        })

};

const dialogVisible = ref(true)
</script>

<template>
    <el-dialog v-model="dialogVisible" title="Login"
        :close-on-click-modal="false" :show-close="false">
        <el-form-item label="email" :label-width="140">
            <el-input v-model="email" autocomplete="off" />
        </el-form-item>
           
        <el-form-item label="password" :label-width="140">
            <el-input v-model="password" autocomplete="off" type="password"/>
        </el-form-item>
        <div align="right">     
        <el-button @click="login" type="primary">Login</el-button>
        </div>
    </el-dialog>
</template>
```
### logoutコンポーネント作成
```javascript:resources/js/Pages/Auth/Logout.vue
<script setup>
import { onMounted } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'

const router = useRouter();

const logout =()=>{
    axios.post('/logout').then((res)=>{
                        console.log('logout response:'+res.data.message);
                        router.push({name:'auth.login'})
                    }).catch((err)=>{
                        console.log('logout error:'+err);
                    })
}

onMounted(()=>{
    logout();
})
</script>
<template></template>
```
### ルートの保護

api.phpファイルを編集し、apiのルートをミドルウェアで保護します。
```php:routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

use App\Http\Controllers\FruitsController;

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Route::group(['middleware' => ['auth:sanctum']], function () {
    Route::get('/fruits/list',[FruitsController::class,'list']);
    Route::put('/fruits/update/{fruit}',[FruitsController::class,'update']);
    Route::post('/fruits/create',[FruitsController::class,'create']);
    Route::delete('/fruits/delete/{fruit}',[FruitsController::class,'delete']);
});
```

## クライアントガード
非ログインの状態ではページをアクセスさせない制御を、vueルーターとpiniaを使用して実装します。

SanctumはHttp通信の認証・保護を行います。ですが、vueの画面遷移はブラウザのjavascript内で完結するので、Sanctumでは保護されません。つまり、vue側でなんらかの制限をかけなければ自由にルーティング：画面遷移できてしまいます。これを制御するためルーティングガードを作成します。
### ライブラリインストール
以下コマンドで必要なライブラリをインストールします。
```
npm install vue-router pinia
```
### レイアウト作成
vueルーターを使用するにあたり、メニュー配置、ページ表示位置を決めるためにレイアウトを作成します。
```javascript:resources/js/Layouts/Layout.vue
<template>
    <el-container>
        <el-aside width="150px">
            <el-menu class="el-menu-vertical-demo" 
                router>
                <el-menu-item index="auth.logout" :route="{ name:'auth.logout' }">
                    ログアウト
                    </el-menu-item>
                <el-menu-item index="fruits.list" :route="{ name:'fruits.list' }">
                    フルーツ
                </el-menu-item>
            </el-menu>
        </el-aside>
        <el-main>
                <router-view></router-view>
        </el-main>
    </el-container>
</template>
```
el-menu-itemのrouteで設定するパラメータは、後にvueルーターで設定します。
### App.vueの編集
アプリケーションの状態ではトップ画面にフルーツ一覧画面が指定されていますが、これをレイアウトに変更します。
```javascript:resources/js/App.vue
<script setup>
import Layout from './Layouts/Layout.vue'
</script>

<template>
    <Layout></Layout>        
</template>
```
### vueルーター有効化
app.jsを編集し、vueルーターを有効化します。
また、コンポーネントのルーティングを設定します。
```javascript:resources/js/app.js
import * as vueRouter from "vue-router";
import Layout from './Layouts/Layout.vue'
import fruitsList from './Pages/Fruits/List.vue'
import Login from './Pages/Auth/Login.vue'
import Logout from './Pages/Auth/Logout.vue'

const routes = [
    {
        path: '/layout',
        name: 'layout',
        component: Layout
    },
    {
        path: '/fruits',
        name: 'fruits.list',
        component: fruitsList
    },
    {
        path: '/',
        name: 'auth.login',
        component: Login
    },
    {
        path: '/auth/logout',
        name: 'auth.logout',
        component: Logout
    },
]

const router = vueRouter.createRouter({
    history: vueRouter.createWebHistory(),
    routes,
});

app.use(router);
```
### 動作テスト
ブラウザでlocalhost:8000にアクセスします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/b0989ff4-6172-37b7-25fa-ba2d92253246.png)
userテーブルの任意のレコードのemailをコピーして貼り付けます。passwordにはpasswordと入力します。ログイン後、フルーツを選択して以下表示されれば成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/3d53bdd7-9d5b-652c-e925-47217bc05899.png)
非ログイン状態をテストします。ログアウトします。そしてlocalhost:8000/fruitsをアクセスします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94957/d173cb2a-70a0-6645-c4b4-611076c8de51.png)
ログアウトの状態なのでapiにアクセスできずデータが表示されません。デベロッパーツールのコンソールを見ると、401 Unauthorizedと表示されています。
今のままではログアウト状態でも一覧画面にアクセスできてしまいます。これを制御するためルーティングガードを実装します。
### pinia有効化
app.jsを編集し、piniaを有効化します。
```javascript:resources/js/app.js
import { createPinia } from 'pinia'
app.use(createPinia());
```
### ログイン状態管理
piniaのstoreを作成し、ログイン状態を管理します。storeはインポートすることでどのコンポーネントからもアクセス可能です。後述のルーティングガードで使用します。
```javascript:resources/js/assets/LoginState.js
import { defineStore } from 'pinia'

export const useLoginState =defineStore('loginstate',{
    state:()=>({
        login:false,
    }),
    actions:{
        setLogin(){
            this.login=true;
        },
        setLogout(){
            this.login=false;
        },
    },
});
```

login/logoutコンポーネントで状態管理機能を使用します。
```javascript:resources/js/Pages/Auth/Login.vue
import {useLoginState} from '../../assets/LoginState.js'//追記

const st = useLoginState();//追記

const login = () =>{

    axios.get('/sanctum/csrf-cookie')
        .then((res)=>{
            axios.post('/login', {
                        email: email.value,
                        password: password.value,
                    }).then((res)=>{
                        console.log('login response:'+res.data.message);
                        console.log('login status:'+res.data.status);
                        if(res.data.status==200){
                            st.setLogin();//追記
                            dialogVisible.value=false;
                        }else{
                            ElNotification({
                                title: 'Error',
                                message: 'ログイン失敗しました',
                                type: 'error',
                            })
                        }
                    }).catch((err)=>{
                        console.log('login error:'+err);
                    })
        })

};
```
```javascript:resources/js/Pages/Auth/Logout.vue
import { useLoginState } from '../../assets/LoginState.js'//追記

const st = useLoginState();//追記

const logout =()=>{
    axios.post('/logout').then((res)=>{
                        console.log('logout response:'+res.data.message);
                        st.setLogout();//追記
                        router.push({name:'auth.login'})
                    }).catch((err)=>{
                        console.log('logout error:'+err);
                    })
}
```

### ルーティングガード作成
vueルーターのイベントハンドラを作成し、ログイン状態のみ画面遷移を許可します。非ログイン状態では強制的にログイン画面に遷移します。
```javascript:resources/js/auth-guard.js
import {useLoginState} from "./assets/LoginState.js"

export const authGuard = (router) => {
    const st=useLoginState();
    router.beforeEach((to)=>{
        
        if (['auth.login'].includes(to.name)) {
            return true;
        }

        if(st.login){
            return true;
        };
        
        return {name: 'auth.login'};
    });
};
```


app.jsを編集してauthGurdを有効化します。
```javascript:resources/js/app.js
import { authGuard } from "./auth-guard";
authGuard(router);
```
### 動作確認
URLでフルーツ一覧画面(localhost:8000/fruits)を直接アクセスしても表示されず、ログイン画面に移ります。ログインすることで同画面にアクセスできます。
## userテーブルユーティリティ作成

アカウントおよびuserテーブルユーティリティとして以下の機能を実装しました。
* ユーザ一覧
* アカウント編集
* ユーザ新規登録

以下のファイルを作成・編集しております。内容については冒頭のGitHubリポジトリを参照下さい。
#### コントローラ・リクエスト
app/Http/Controllers/UsersController.php
app/Http/Controllers/Auth/PasswordController.php
app/Http/Requests/ProfileUpdateRequest.php
#### コンポーネント・レイアウト
resources/js/Pages/Auth/List.vue
resources/js/Pages/Auth/Edit.vue
resources/js/Pages/Auth/Add.vue
resources/js/Layouts/Layout.vue
#### vue-router
resources/js/app.js
#### web api
routes/api.php

## 最後に
紹介したアプリははネット上のTipsを集めて作り上げたものですが、試行錯誤したところもあります。自分のまとめの意味でも書きましたが、何かヒントになるようであれば幸いです。
最後までご覧いただきありがとうございました。