コンテンツ作るぞ…よりそのスタートラインに持ってくのが大変だったりしますよね…
実務でも深追いできずにコンテンツのほう入ってたりしたので
そんなこんなで学習&アウトプットも兼ねて
ローカルで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)にインストールしてから
composer create-project laravel/laravel temp
1階層上に移動しました
(VSCode上で普通に移動しました)
http://localhost/server/
でLaravelの画面が表示されるか確認。
最初は storage フォルダのパーミッションのエラーが出るので chown します
chown -R www-data:www-data storage/
.envの編集
DB(コンテナ)に接続できるようにしておきます。
DB_HOST=db
DB_DATABASE=dev_db
DB_USERNAME=root
DB_PASSWORD=root
ちなみにDB自体の確認用には私は Adminer をよく使います。
server/public/adminer.php を置いて接続確認したりします。
タイムゾーンの設定
Laravelからnow()などで登録した日時が日本時間になってなかったので…
- 'timezone' => 'UTC',
+ 'timezone' => 'Asia/Tokyo',
その他
あと環境構築とは関係ないですが、私はフォーマッターにphp-cs-fixerを使ってるので
.php-cs-fixer.dist.php を作成しておくなど…
とりあえずLaravel本体の準備はこんなとこです
Vue+Vite+TypeScript環境の構築
Cliは爆速なViteを使用します。
TypeScriptのテンプレートをclient/直下にインストールしますが、
Laravel同様空のディレクトリでないと…なのでこちらも適当なディレクトリ(temp)にインストールして
yarn
yarn create vite temp --template vue-ts
1階層上に移動しました
移動したら、諸々のモジュールをインストールします
yarn
たぶんこの状態でも実行して画面を確認できます。
デフォルトだと3000番ポートが使用されます。
yarn dev --host
localhostの場合、--hostがないとDockerにアクセスできないぽかったですが
このあたりの設定は vite.config.ts で調整可能です
なおローカルで面倒だったのでとりあえず全体的にhttpで構成しています
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,
},
});
上記設定後は
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側のコンテナで
composer require laravel/passport
php artisan migrate
暗号化キーの生成
次に、暗号化キーを生成します
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の修正
- use Laravel\Sanctum\HasApiTokens;
+ use Laravel\Passport\HasApiTokens;
AuthServiceProviderにPassport::routes()を追加します。
認証方法を制限する場合、以下のように引数で指定できます。
参考:https://qiita.com/h1na/items/25a08122418df782d2b9
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
を追加
'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
に戻る前提で進めます
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の設定が必要です。
- 'paths' => ['api/*', 'sanctum/csrf-cookie'],
- 'allowed_origins' => ['*'],
+ 'paths' => ['api/*', 'sanctum/csrf-cookie', 'oauth/token'],
+ 'allowed_origins' => ['http://localhost:3000'],
認証画面を作成
ユーザーの管理、認証はLaravel側が行うので認証画面を作成します。
こちらもシンプルなLaravel Breezeを使用します。
Laravel Breezeをインストール
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 を使用しますのでインストールしておきます
yarn add vue-router@next js-pkce
ルーティングの設定
ページを作りつつルーティングも設定していくので先に簡単に書いておきます…
パスと対応したコンポーネントを指定することでURLを設定します。
初めての方は以下が参考になります。
https://www.vuemastery.com/blog/vue-router-a-tutorial-for-vue-3/
Home.vue、About.vueはコンテンツになるページです。
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 };
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)のみにしておきます
<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を作成してその中にコンテンツページを表示するようにしておきます。
<template>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view />
</template>
このコンポーネントの入れ子のルーティングは以下のように書けます
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などに保存する場合は不要です。
ここでは変数内に持っているだけという状態で実装するため、その入れ物です。
(タブを閉じたら消えます)
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するだけのプラグインを作成し、
import { authProps, AuthStateSymbol } from '../store/authContext';
export const auth = {
install(app: any) {
app.provide(AuthStateSymbol, authProps());
},
};
追加しておきます
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)
というコンポーネント
<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に仕込みます
<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)し、
ここでは再度トップ画面に戻ります
<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で公開します
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に遷移、
認証・承認後トークンをゲットして、ページがみられるようになると思います。
(これはElementPlusなどが入ってるので見た目違うと思いますが…)
その後
あとは
- Laravel側にAPIを実装
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