LoginSignup
5
4

More than 1 year has passed since last update.

Laravel+Vue&PKCEのSPA環境構築メモ

Last updated at Posted at 2022-01-25

コンテンツ作るぞ…よりそのスタートラインに持ってくのが大変だったりしますよね…
実務でも深追いできずにコンテンツのほう入ってたりしたので
そんなこんなで学習&アウトプットも兼ねて
ローカルで1から環境構築してみた際のメモを書いてみました。

https://github.com/pei-miyapei/laravel-vue-spa
(こちらは簡単なAPIの実装とUIフレームワーク(ElementPlus)を導入した状態です)

追記:React版も作成しました

※ SPAフロントエンド側のVueの箇所のみを、Reactで書いたものになります

構成

API server コンテナ(SPA バックエンド、ユーザー管理)

  • nginx
  • php 8
  • laravel (passport, breeze)
  • mysql 8

SPA クライアントコンテナ(SPA フロントエンド)

  • Vite
  • Vue 3.2
  • TypeScript
  • vue-router
  • js-pkce

認証

  • Authorization Code Grant Flow with Proof Key for Code Exchange(PKCE 4?)

クライアント側に js-pkce 、IdP 側に Laravel Passport を利用する形です

このデモではトークンを永続化していません。
リロードしたら消えますが、Laravel側のセッションが生きていれば認証は飛ばせます。
(必要があればCookieとかに保存するなど)

参考

OAuthフローについて
https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f
認可コードフロー+PKCEについては
https://zenn.dev/zaki_yama/articles/oauth2-authorization-code-grant-and-pkce
https://www.osscons.jp/joar0xbhj-537/

すでに同様の実装を行っている方々の記事など(やや構成など異なります)
https://wonwon-eater.com/laravel-oauth-pkce/
https://qiita.com/okmt_okmt_/items/f70c7552b0ecba3375f6

コンテナ

https://github.com/pei-miyapei/laravel-vue-spa/tree/main/docker
DockerfileはURLの通りです。

開発はVSCodeで行っています
server/(laravel側)、client/(Vue側)に
それぞれリモートコンテナで接続して進めていきます。
最初の段階ではいずれも中身は.devcontainerのみの状態です

初期データベースは dev_db というDBが作成されるようになっています。

Laravelインストール

composerを使用してLaravelをインストールします。
server/ 直下に用意しますが、空でないディレクトリにインストールできないので、
適当なディレクトリ(temp)にインストールしてから

bash
composer create-project laravel/laravel temp

1階層上に移動しました
(VSCode上で普通に移動しました)

http://localhost/server/
でLaravelの画面が表示されるか確認。

最初は storage フォルダのパーミッションのエラーが出るので chown します

bash
chown -R www-data:www-data storage/

.envの編集

DB(コンテナ)に接続できるようにしておきます。

server/.env
DB_HOST=db
DB_DATABASE=dev_db
DB_USERNAME=root
DB_PASSWORD=root

ちなみにDB自体の確認用には私は Adminer をよく使います。
server/public/adminer.php を置いて接続確認したりします。

タイムゾーンの設定

Laravelからnow()などで登録した日時が日本時間になってなかったので…

server/config/app.php
-    'timezone' => 'UTC',
+    'timezone' => 'Asia/Tokyo',

その他

あと環境構築とは関係ないですが、私はフォーマッターにphp-cs-fixerを使ってるので
.php-cs-fixer.dist.php を作成しておくなど…
とりあえずLaravel本体の準備はこんなとこです

Vue+Vite+TypeScript環境の構築

Cliは爆速なViteを使用します。
TypeScriptのテンプレートをclient/直下にインストールしますが、
Laravel同様空のディレクトリでないと…なのでこちらも適当なディレクトリ(temp)にインストールして

bash
yarn
yarn create vite temp --template vue-ts

1階層上に移動しました

移動したら、諸々のモジュールをインストールします

bash
yarn

たぶんこの状態でも実行して画面を確認できます。
デフォルトだと3000番ポートが使用されます。

bash
yarn dev --host

localhostの場合、--hostがないとDockerにアクセスできないぽかったですが
このあたりの設定は vite.config.ts で調整可能です
なおローカルで面倒だったのでとりあえず全体的にhttpで構成しています

vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    host: true,
    port: 3000,
    // https: true,
  },
});

上記設定後は

bash
yarn dev

のみで起動できます。

その他

コード補完について

補完についてはいろいろパターンがあるようですが
Vue3にはVolarが良いようなので
私はVolar + Take Over Mode を使用しています。

Take Over Modeについては下記
https://github.com/johnsoncodehk/volar/discussions/471

Take Over Modeは、VSCodeの組み込みTypeScript拡張を使用せず、
Vue言語サーバーを使用してVue + TS言語サポートを提供します。

  • Vetur(とVue 3 snippets)は競合するため削除
  • 拡張機能で @builtin typescript を検索して
    TypeScript and JavaScript Language Features無効にする(ワークスペース)
    (=VSCodeの組み込みTypeScript拡張を無効化)

認証周りの構築

とりあえずここまででLaravelとVue環境は揃った状態です。

Laravelに認証機能(Laravel Passport)を追加

ユーザー認証、トークン認証を使えるようにするため Laravel Passport をインストールします。
おおよそ上記URLの「インストール」の項をなぞっていくだけです。
(ここでは passport:install 使っていないなど多少異なります)

Laravel側のコンテナで

bash
composer require laravel/passport
php artisan migrate

暗号化キーの生成

次に、暗号化キーを生成します

bash
php artisan passport:keys

php artisan passport:install でも生成されます。
  ただパスワード認証やパーソナルアクセストークンのクライアントなども登録されます
  (今回使用してない)
https://readouble.com/laravel/8.x/ja/passport.html
https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f

UserモデルとAuthServiceProviderの修正

sever/app/Models/User.php
- use Laravel\Sanctum\HasApiTokens;
+ use Laravel\Passport\HasApiTokens;

AuthServiceProviderにPassport::routes()を追加します。
認証方法を制限する場合、以下のように引数で指定できます。
参考:https://qiita.com/h1na/items/25a08122418df782d2b9

server/app/Providers/AuthServiceProvider.php
class AuthServiceProvider extends ServiceProvider
{
    /**
     * The policy mappings for the application.
     *
     * @var array<class-string, class-string>
     */
    protected $policies = [
        'App\Models\Model' => 'App\Policies\ModelPolicy',
    ];

    /**
     * Register any authentication / authorization services.
     */
    public function boot(): void
    {
        $this->registerPolicies();

        if (!$this->app->routesAreCached()) {
            Passport::routes(function (RouteRegistrar $router): void {
                $router->forAuthorization();
                $router->forAccessTokens();
            });
        }

        Passport::tokensExpireIn(now()->addMinutes(30));
        Passport::refreshTokensExpireIn(now()->addDays(5));
    }
}

config/auth.php の修正

api を追加

config/auth.php
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],

PKCE対応のクライアント生成

https://readouble.com/laravel/8.x/ja/passport.html
こちらの「PKCEを使った認可コードグラント」の項ですが、
認可リクエストする側はVueで実装しますので、クライアントの生成のみです。

ここで指定するURLは、認証後にVue側にリダイレクトするURLになります。
ここでは初期値の /auth/callback に戻る前提で進めます

bash
php artisan passport:client --public

 Which user ID should the client be assigned to?:
 > 1

 What should we name the client?:
 > demo

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

New client created successfully.
Client ID: 1
Client secret: 

※ ただこれは oauth_clients テーブルにレコードが登録されるだけですので、
  私は後ほどSeederで登録するようにしました

CORS設定

localhost同士ですがトークンを交換する際、
Vue側が3000番ポートで異なるオリジンからのアクセスになるのでCORSの設定が必要です。

server/config/cors.php
-    'paths' => ['api/*', 'sanctum/csrf-cookie'],
-    'allowed_origins' => ['*'],
+    'paths' => ['api/*', 'sanctum/csrf-cookie', 'oauth/token'],
+    'allowed_origins' => ['http://localhost:3000'],

認証画面を作成

ユーザーの管理、認証はLaravel側が行うので認証画面を作成します。
こちらもシンプルなLaravel Breezeを使用します。

Laravel Breezeをインストール
bash
composer require laravel/breeze --dev
php artisan breeze:install
npm install
npm run dev

超便利ですね!

ユーザーを作成

今のところユーザーがいないので、/register でユーザーを作成できます。
http://localhost/server/register

※ 後ほどSeederで登録するようにしました

/login でログイン画面が表示され、登録したユーザーでログインできます。
http://localhost/server/login

SPA側の実装

ここからはVue側で実装していきます。

  • ルーティングの設定
  • トークンを一時的に保持する入れ物
  • トークンチェック&認可リクエストするガワページ
    (レイアウトというかコンテンツのコンテナというか…囲うだけのページです)
  • 認可コードコールバック用ページ
    (Laravelで認証後戻ってくるページ。受け取った認可コードとトークンを交換します。
     Laravel側で指定した /auth/callback のページになります。)

vue-router, js-pkceをインストール

ルーティングにvue-router、
PKCE周りの処理は js-pkce を使用しますのでインストールしておきます

bash
yarn add vue-router@next js-pkce

ルーティングの設定

ページを作りつつルーティングも設定していくので先に簡単に書いておきます…
パスと対応したコンポーネントを指定することでURLを設定します。

初めての方は以下が参考になります。
https://www.vuemastery.com/blog/vue-router-a-tutorial-for-vue-3/

Home.vue、About.vueはコンテンツになるページです。

client/src/routes/router.ts
import { createWebHistory, createRouter } from 'vue-router';
import HomeVue from '../views/Home.vue';
import AboutVue from '../views/About.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: HomeVue,
  },
  {
    path: "/about",
    name: "About",
    component: AboutVue,
  },
];

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

export { router };
client/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './routes/router';    // ← 追記

// ↓ routerを追加する形に書き換え
const app = createApp(App);
app.use(router);
app.mount('#app');

App.vueのコンテンツはvue-routerの中身(router-view)のみにしておきます

client/src/App.vue
<template>
  <router-view />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
</style>

これで / でHome.vueが /about でAbout.vue が表示されます
こんな感じです。

Layout.vueを追加しておく

ここでApp.vueにrouter-linkを書けばリンクが張れるわけですが、
未認証時に丸ごと非表示にしたいため、

別途レイアウト用のVueを作成してその中にコンテンツページを表示するようにしておきます。

client/src/views/Layout.vue
<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </div>
  <router-view />
</template>

このコンポーネントの入れ子のルーティングは以下のように書けます

client/src/routes/router.ts
import { createWebHistory, createRouter } from 'vue-router';
import HomeVue from '../views/Home.vue';
import AboutVue from '../views/About.vue';
import LayoutVue from '../views/Layout.vue';

const routes = [
  {
    path: '/',
    component: LayoutVue,
    children: [
      {
        path: '',
        name: 'Home',
        component: HomeVue,
      },
      {
        path: '/about',
        name: 'About',
        component: AboutVue,
      },
    ],
  },
];

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

export { router };

なおコンポーネントがネストされることと、ルートからのパスは無関係に設定できます。
ネストされたルートについての詳細はこちら
https://router.vuejs.org/ja/guide/essentials/nested-routes.html

トークンを保持する入れ物

これに関してはCookieなどに保存する場合は不要です。
ここでは変数内に持っているだけという状態で実装するため、その入れ物です。
(タブを閉じたら消えます)

client/src/store/authContext.ts
import { inject, InjectionKey, readonly, ref } from 'vue';

export const authProps = () => {
  const accessToken = ref('');
  const refreshToken = ref('');

  const hasToken = () => accessToken.value !== '';

  const setTokens = (newAccessToken = '', newRefreshToken = '') => {
    accessToken.value = newAccessToken;
    refreshToken.value = newRefreshToken;
  };

  return readonly({
    accessToken,
    refreshToken,
    hasToken,
    setTokens,
  });
};
export type AuthProps = ReturnType<typeof authProps>;

export const AuthStateSymbol: InjectionKey<AuthProps> = Symbol('AuthState');

export const injectAuth = (): AuthProps => {
  const auth = inject(AuthStateSymbol);
  if (auth === undefined) {
    throw new Error('auth は provide されていません。');
  }

  return auth;
};

これをprovideするだけのプラグインを作成し、

client/src/plugins/auth.ts
import { authProps, AuthStateSymbol } from '../store/authContext';

export const auth = {
  install(app: any) {
    app.provide(AuthStateSymbol, authProps());
  },
};

追加しておきます

client/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './routes/router';
import { auth } from './plugins/auth';    // ← 追記

const app = createApp(App);
app.use(router);
app.use(auth);    // ← 追記
app.mount('#app');

ページの作成

残りの

  • トークンチェック&認可リクエストするガワページ
  • 認可コードコールバック用ページ

を作成します。

トークンチェック&認可リクエストするガワページ
  • 認可が必要なページにかませて使用
  • トークンがなければ認可リクエストを発行(if ~)
  • トークンがなければデフォルトスロットの中身(コンテンツ)の描画を行わない(v-if)
  • トークンは先ほど作成した、プラグインでprovideされた入れ物を召喚して確認(injectAuth)

というコンポーネント

client/src/components/Auth/AuthGuard.vue
<script setup lang="ts">
import PKCE from 'js-pkce';
import { injectAuth } from '../../store/authContext';

const { hasToken } = injectAuth();

if (!hasToken()) {
  const pkce = new PKCE({
    client_id: '1', // `php artisan passport:client --public` したときのIDです
    redirect_uri: location.origin + '/auth/callback', // 戻ってくるURL
    authorization_endpoint: 'http://localhost/server/oauth/authorize', // Laravel側の認可エンドポイント
    requested_scopes: '*',
  });
  location.replace(pkce.authorizeUrl());
}
</script>

<template v-if="hasToken()">
  <slot />
</template>

を作成。

認証が必要なページにこのコンポーネントを導入します。
今回はコンテンツページは全部認証が必要なページとして、
最初に作成したグローバルメニューを持ったLayout.vueに仕込みます

client/src/views/Layout.vue
<script setup lang="ts">
import AuthGuardVue from '../components/Auth/AuthGuard.vue';
</script>

<template>
  <AuthGuardVue>
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view />
  </AuthGuardVue>
</template>
認可コードコールバック用ページ

Laravelで認証後戻ってくるページ。受け取った認可コードとトークンを交換します。
Laravel側で指定した /auth/callback のページになります。

js-pkceのexchangeForAccessTokenというメソッドでトークンを受け取れます。
それを用意した入れ物(injectAuth)に保存(auth.setToken)し、
ここでは再度トップ画面に戻ります

src/views/Auth/AuthorizationCallback.vue
<script setup lang="ts">
import PKCE from 'js-pkce';
import { useRouter } from 'vue-router';
import { injectAuth } from '../../store/authContext';

const pkce = new PKCE({
  client_id: '1', // `php artisan passport:client
  redirect_uri: location.origin + '/auth/callback',
  token_endpoint: 'http://localhost/server/oauth/token',
});
const { setTokens } = injectAuth();
const router = useRouter();

pkce.exchangeForAccessToken(document.location.href).then((response) => {
  setTokens(response.access_token, response.refresh_token);
  // 認証後に遷移するページへ
  router.replace({ name: 'Home' });
});
</script>

<template></template>

このページを /auth/callback のURLで公開します

client/src/routes/router.ts
import { createWebHistory, createRouter } from 'vue-router';
import HomeVue from '../views/Home.vue';
import AboutVue from '../views/About.vue';
import AuthorizationCallbackVue from '../views/Auth/AuthorizationCallback.vue'; // ← 追加
import LayoutVue from '../views/Layout.vue';

const routes = [
  {
    path: '/auth/callback', // このブロックを追加
    name: 'TokenCallback',
    component: AuthorizationCallbackVue,
  },
  {
    path: '/',
    component: LayoutVue,
    children: [
      {
        path: '',
        name: 'Home',
        component: HomeVue,
      },
      {
        path: '/about',
        name: 'About',
        component: AboutVue,
      },
    ],
  },
];

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

export { router };

一応完了

とりあえずこれで、Vue側にアクセスするとトークンがないためLaravelに遷移、
認証・承認後トークンをゲットして、ページがみられるようになると思います。

pkce4.gif
(これはElementPlusなどが入ってるので見た目違うと思いますが…)

その後

あとは
* Laravel側にAPIを実装

server/routes/api.php
Route::middleware('auth:api')->group(function (): void {
    Route::prefix('demos')->group(function (): void {
        Route::get('demo', [DemoController::class, 'find']);
        Route::put('demo', [DemoController::class, 'upsert']);
    });
});

  • Vue側でトークンを使用してAPIをリクエストする
axios.get('http://localhost/server/api/demos/demo', {
  headers: {
    Authorization: `Bearer ${token}`,
  }
})

(をもっと汎用的に)書いていく感じになります

締め

…と、通しで書いたら長くなってしまった…
最初にメモとか書きましたが実際にはあとからまとめて書きました…抜け・漏れがあるかもしれません。
おかしな点などがありましたらこっそりご教示ください…orz

5
4
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
5
4