Laravel SanctumでSPA認証

前回は「Laravel SanctumでBearerトークンによるAPI認証」を行いましたが、今回はLaravel Sanctumのもうひとつの認証、トークンを使用せずクッキーベースのセッション認証であるSPA認証を行います。
Laravel SanctumでBearerトークンによるAPI認証」に追加設定することになりますのでLaravel SanctumのインストールやUserモデルの設定、トークンの発行はご参照願います。

開発環境

Laradock v10
Laravel 7
Vue 2.6
Vuetify 2.2

SPA認証用のSanctumのミドルウェア追加

SPA認証用にapp/Http/Kernel.phpのapiミドルウェアグループにSanctumのミドルウェアを追加します。

use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;	// 追加

'api' => [
    EnsureFrontendRequestsAreStateful::class,	// 追加
    'throttle:60,1',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],
-----

CORS対策に追加

'paths' => ['api/*'],
↓
'paths' => ['api/*', 'sanctum/csrf-cookie'],	// 変更

.env設定

Laravel Sanctumが対応するドメインを設定します。
ローカルでは動作しても外部サーバで動作しないときはチェックしましょう!

SANCTUM_STATEFUL_DOMAINS=****.***

※ドメインがlocalhostでない場合、ドメイン名を設定します。カンマ区切りで複数設定できるみたいですね。

    /*
    |--------------------------------------------------------------------------
    | Stateful Domains
    |--------------------------------------------------------------------------
    |
    | Requests from the following domains / hosts will receive stateful API
    | authentication cookies. Typically, these should include your local
    | and production domains which access your API via a frontend SPA.
    |
    */

    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1,127.0.0.1:8000,::1')),

認証

/sanctum/csrf-cookieルートへのリクエストを作成し、アプリケーションのCSRF保護を初期化してからログイン処理を実行しなければなりません。

axios.get('/sanctum/csrf-cookie').then(response => {
    // ログイン処理…
});

ログイン後はAPIルートに対する認証は自動的に行われます。

Route::middleware('auth:sanctum')->group(function(){
	//
});

テスト

以上を踏まえて実装してみましょう。

ログインコントローラー

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    /**
     * ログイン
     *
     * @param  \Illuminate\Http\Request $request
     *
     * @return Response
     */
    public function login(Request $request)
    {
        $result = false;
        $status = 401;
        $message = 'ユーザが見つかりません。';
        $user = null;
        $credentials = $request->only('email', 'password');
        if (Auth::attempt($credentials)) {
            // Success
            $result = true;
            $status = 200;
            $message = 'OK';
            $user = Auth::user();
            // ※古いトークン削除&新しいトークン生成
            $user->tokens()->where('name', 'token-name')->delete();
            $token = $user->createToken('token-name')->plainTextToken;
        }
        return response()->json(['result' => $result, 'status' => $status, 'message' => $message]);
    }

    /**
     * ログアウト
     *
     * @return Response
     */
    public function logout()
    {
        Auth::logout();
        $result = true;
        $status = 200;
        $message = 'ログアウトしました。';
        return response()->json(['result' => $result, 'status' => $status, 'message' => $message]);
    }
}

「※古いトークン削除&新しいトークン生成」は、すでにトークンが存在していれば必要ありません。

APIルーティング

<?php

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

Route::post('/login', 'Api\LoginController@login');
Route::get('/logout', 'Api\LoginController@logout');

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

Vue-router

import Vue from 'vue'
import Router from 'vue-router'

import SpaLogin from './components/SpaLoginComponent.vue'
import SpaUser from './components/SpaUserComponent.vue'

Vue.use(Router)

export default new Router({
    mode: 'history',
    routes: [
        {
            path: '/spalogin',
            name: 'SpaLogin',
            component: SpaLogin,
        },
        {
            path: '/spauser',
            name: 'SpaUser',
            component: SpaUser,
        },
    ],
})

Vueコンポーネント

<template>
  <div class="m-auto" style="width: 320px;">
    <h1>SPA Login</h1>

    <div>
      <v-form
        ref="form"
        v-model="valid"
      >

        <v-text-field
          v-model="email"
          :rules="[rules.required]"
          label="E-MAIL"
        ></v-text-field>
        <v-text-field
          v-model="password"
          :rules="[rules.required]"
          :append-icon="passwordShow ? 'mdi-eye' : 'mdi-eye-off'"
          :type="passwordShow ? 'text' : 'password'"
          name="password"
          label="Password"
          @click:append="passwordShow = !passwordShow"
        ></v-text-field>

        <v-btn :disabled="!valid" color="primary" @click="login">LOGIN</v-btn>
        <v-btn color="red" @click="logout">LOGOUT</v-btn>
      </v-form>
      <p class="mt-2 text-danger">{{ loginMessage }}</p>
    </div>

    <div>
      <v-btn color="success" class="mt-2" @click="getUser">GetUser</v-btn>
      <p class="mt-2 text-danger">{{ getUserMessage }}</p>
      <table class="table mt-2">
        <tr>
          <th>ID</th>
          <td>{{ user.id }}</td>
        </tr>
        <tr>
          <th>Name</th>
          <td>{{ user.name }}</td>
        </tr>
        <tr>
          <th>E-Mail</th>
          <td>{{ user.email }}</td>
        </tr>
      </table>
    </div>

    <router-link to="/SpaUser">
      <v-btn color="warning" class="mt-2">SPA User</v-btn>
    </router-link>

  </div>
</template>

<script>
  export default {
    data() {
      return {
        valid: true,
        isLogin: false,
        email: '',
        password: '',
        passwordShow: false,
        loginMessage: '',
        user: {},
        getUserMessage : '',
        rules: {
          required: value => !!value || 'Required.',
        }
      }
    },
    created() {
//      this.logout()
    },
    methods: {
      login() {
        axios.get('/sanctum/csrf-cookie')
        .then((res) => {
          axios.post('/api/login', {
            email: this.email,
            password: this.password,
          })
          .then((res) => {
            console.log(res.data)
            if (res.data.result) {
              this.isLogin = true
              this.getUserMessage = ''
            } else {
              this.loginMessage = res.data.message
            }

          })
          .catch((err) => {
            console.log(err)
          })

        })
        .catch((err) => {
          //
        })
      },
      logout() {
        axios.get('/api/logout')
        .then((res) => {
          this.user = {}
          this.isLogin = false
        })
        .catch((err) => {
          console.log(err)
        })
      },
      getUser() {
        this.user = {}
        this.getUserMessage = ''
        axios.get('/api/user')
        .then((res) => {
          this.user = res.data
        })
        .catch((err) => {
          console.log(err)
          this.getUserMessage = '取得できません。'
        })
      },

    },
  }
</script>
<template>
  <div class="m-auto" style="width: 320px;">
    <h1>SPA User</h1>

    <div>
      <router-link to="/SpaLogin">
        <v-btn color="primary" class="mt-2">SPA Login</v-btn>
      </router-link>
      <p class="mt-2 text-danger">{{ getUserMessage }}</p>
      <table class="table mt-2">
        <tr>
          <th>ID</th>
          <td>{{ user.id }}</td>
        </tr>
        <tr>
          <th>Name</th>
          <td>{{ user.name }}</td>
        </tr>
        <tr>
          <th>E-Mail</th>
          <td>{{ user.email }}</td>
        </tr>
      </table>
    </div>

  </div>
</template>

<script>
  export default {
    data() {
      return {
        user: {},
        getUserMessage : '',
      }
    },
    created() {
      this.getUser()
    },
    methods: {
      getUser() {
        this.user = {}
        this.getUserMessage = ''
        axios.get('/api/user')
        .then((res) => {
          this.user = res.data
        })
        .catch((err) => {
          console.log(err)
          this.getUserMessage = '取得できません。'
        })
      },

    },
  }
</script>
laravel-sanctum-spa

後記

Laravel SanctumでのSPA認証はCookieベースのセッション認証なのでリロードされても大丈夫ですね!

有効期限はconfig/sanctum.phpのexpirationで設定(分単位)できるようです。

'expiration' => null,