この記事では、サーバーサイドレンダリングする Universal モードの Nuxt.js フロントアプリと、それとは異なるサブドメインで運用される Web API という構成でクッキー認証とCORSを実現する方法を紹介します。
Nuxt でアプリケーションを構築する場合、サーバーサイド(というかデータ取得部分)は Web API になるでしょう。そして Web API での認証といえば JWT を用いたステートレスなトークン認証が一般的かと思います。しかし JWT を LocalStorage に格納する実装は脆弱なアンチパターンであると主張する記事や以下参考リンクの記事を読み、フロントエンド + Web API な構成でもクッキーで認証を行い CORS でクロスサイトなアクセスを防ぐ実装パターンを考えてみました。
今回の実装パターンは以下の記事を参考にしました。
- 独自ヘッダをチェックするだけのステートレスなCSRF対策は有効なのか? — A Day in Serenity (Reloaded) — PHP, FuelPHP, Linux or something
- オリジン間リソース共有 (CORS) | MDN
- CORS(Cross-Origin Resource Sharing)について整理してみた | DevelopersIO
Web API
今回はサーバーサイドの Web API は Laravel での構築例を紹介します。理由は本物の認証機能が簡単に用意できるからです。他の言語・フレームワークでも考え方は同様かと思います。
Laravel ユーザーではない、または単に Nuxt の構築例のみを知りたい方はこちらからどうぞ。
また、サンプルコードはこちらのリポジトリにも格納しています。
Laravel プロジェクトを作成する
まずは composer
コマンドでプロジェクトを新規作成します。
$ composer create-project laravel/laravel nuxt-auth-sample
データをセットアップする
認証に必要なユーザーテーブルとテストデータを準備します。
今回はデモなので簡単のために SQLite を使用します。
DB_CONNECTION=sqlite
データベースファイルを作成してマイグレーションを実行します。
$ touch database/database.sqlite
$ php artisan migrate
シーダーを作成してユーザーのテストデータを挿入します。
$ php artisan make:seeder UserTableSeeder
<?php
use Illuminate\Database\Seeder;
class UserTableSeeder extends Seeder
{
public function run()
{
factory(\App\User::class)->create();
}
}
ランダムなユーザーを作成するファクトリーは database/factories/UserFactory.php
に用意されていますので、シーダーではそれを呼び出すだけです。
シーダーを実行します。
$ php artisan db:seed --class=UserTableSeeder
どのようなデータが入ったか確認しておきましょう。
$ php artisan tinker
>>> \App\User::first();
=> App\User {#2917
id: "1",
name: "Ruthie Rutherford",
email: "mzulauf@example.org",
email_verified_at: "2018-12-25 16:23:50",
created_at: "2018-12-25 16:23:50",
updated_at: "2018-12-25 16:23:50",
}
このメールアドレスをあとで作るログイン画面で入力すればよいです。ちなみにプリセットのファクトリで作成されるパスワードは「secret」です。
認証コントローラー
続いてログインコントローラーを編集します。デフォルトの挙動では SPA ではなくマルチページのアプリケーションが想定されているので、ログイン・ログアウトが成功するとリダイレクトレスポンスが返されます。しかし今回はフロントは Nuxt で制御して Laravel には API に専念させる構成を採るため、ログイン・ログアウトそれぞれに成功した場合のレスポンスを変更します。
class LoginController extends Controller
{
/* 中略 */
/**
* ログイン成功
*/
protected function authenticated(Request $request, $user)
{
return $user;
}
/**
* ログアウト成功
*/
protected function loggedOut(Request $request)
{
return response()->json();
}
}
上記の通り、app/Http/Controllers/Auth/LoginController.php
に authenticated
メソッドと loggedOut
メソッドを追記します。これらは AuthenticatesUsers
トレイトで空のメソッドとして定義されていて(参考① / 参考②)、カスタマイズして返却値を記述するとデフォルトのレスポンスより優先される仕組みになっています(参考③ / 参考④)。
それぞれのメソッドの中身は要件次第ですが、今回はログイン成功後にユーザーデータを、ログアウト成功後には単に 200 OK を返す内容にしました。
CORS
次に Laravel アプリを CORS(オリジン間リソース共有)に対応させます。
パッケージのインストール
検索したところ、laravel-cors というパッケージが比較的広く利用されているようです。
$ composer require barryvdh/laravel-cors
config 設定
パッケージがインストールできたら設定ファイルを作成します。
$ php artisan vendor:publish --provider="Barryvdh\Cors\ServiceProvider"
config/cors.php
に設定ファイルが作成されるので、以下の内容に編集します。
<?php
return [
'supportsCredentials' => true, // ★
'allowedOrigins' => [env('CORS_ALLOWED_ORIGIN')], // ★
'allowedOriginsPatterns' => [],
'allowedHeaders' => ['*'],
'allowedMethods' => ['*'],
'exposedHeaders' => [],
'maxAge' => 0,
];
デフォルト(ファイル生成時)からの変更点は以下の2点(★)です。
- クッキーの送受信を行うため、
supportsCredentials
をtrue
に設定します。 - アクセスを許可するオリジン
allowedOrigins
に.env
の設定値を適用させます。
クロスオリジンのアクセスを許可するオリジンを .env
に追記します。
CORS_ALLOWED_ORIGIN=http://app.nuxt-auth-sample.test:3000
後ほど Nuxt 側を構築する際に設定しますが、フロントは上記の URL で動作させます。
ミドルウェアとルーティング
CORS が有効な API のためのミドルウェアグループとルーティングを新しく作成します。
まず app/Providers/RouteServiceProvider.php
に、ルーティングファイルを読み込んでミドルウェアグループと紐づけるメソッドを追加します。
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
$this->mapCorsRoutes(); // 追加
}
// 追加
protected function mapCorsRoutes()
{
Route::middleware('cors')
->namespace($this->namespace)
->group(base_path('routes/cors.php'));
}
Ajax アクセスのみを許可するミドルウェアを新規作成します。
<?php
namespace App\Http\Middleware;
use Closure;
class AjaxOnly
{
public function handle($request, Closure $next)
{
if ($request->ajax()) {
return $next($request);
}
abort(403);
}
}
app/Http/Kernel.php
にミドルウェアグループ cors
を追加します。
protected $middlewareGroups = [
/* 中略 */
// 新しいミドルウェアグループを追加
'cors' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\AjaxOnly::class,
\Barryvdh\Cors\HandleCors::class,
],
];
基本的にはクッキー認証を想定した web
ミドルウェアグループと同じ内容ですが、VerifyCsrfToken
は外して AjaxOnly
および HandleCors
を追加します。CSRF 対策はトークンではなく CORS でのオリジン制限で補います。
ルーティング定義ファイル routes/cors.php
を追加します。
<?php
// 1.
Route::get('/user', function () {
return Auth::user();
});
// 2.
Route::get('/message', function () {
return ['text' => 'Hello, ' . Auth::user()->name];
})->middleware('auth');
// 3.
Auth::routes();
- ログインチェックのためのルート。現在のログインユーザを返す。ログインしていなければ返却値は
null
となる。 - 認証されたユーザーのみアクセスできるルート。
- 認証機能のルート。
クッキーの送信先
最後にクッキーの送信先を設定します。デフォルトでは同じドメインにしかクッキーは送信されないので、サブドメインも含めてクッキーを送信するように .env
に記述を追加します。
SESSION_DOMAIN=nuxt-auth-sample.test
ちなみに nuxt-auth-sample.test
というドメインがどこから出てきたかというと、私は laravel-valet 環境で nuxt-auth-sample
というプロジェクトを作成しているために http://nuxt-auth-sample.test という URL でアプリが起動します。Homestead など別の方法で環境構築している場合はそれぞれ動作するドメインに合わせて設定値を決めてください。
サーバーサイドの構築はここまでです。
Nuxt アプリ
次にフロントエンドを Nuxt で構築します。
サンプルコードはこちらのリポジトリにも格納しています。
アプリの作成と設定
アプリの作成
create-nuxt-app
コマンドでアプリを作成します。
$ npx create-nuxt-app auth-sample
? Project name auth-sample
? Project description My flawless Nuxt.js project
? Use a custom server framework none
? Use a custom UI framework none
? Choose rendering mode Universal
? Use axios module yes
? Use eslint no
? Use prettier no
? Author name Masahiro Harada
? Choose a package manager yarn
config 設定
Nuxt でも .env
で設定値を管理します。まずはライブラリをインストール。
$ yarn add @nuxtjs/dotenv
プロジェクトルートに .env
ファイルを作成し、API の URL を記述します。
API_URL=http://nuxt-auth-sample.test
nuxt.config.js
に以下の記述を追加します。
require('dotenv').config() // ★ 追加 1.
module.exports = {
/* 中略 */
plugins: [
'~/plugins/axios' // ★ 追加 2.
],
modules: [
'@nuxtjs/axios',
'@nuxtjs/dotenv' // ★ 追加 3.
],
axios: {
baseURL: process.env.API_URL, // ★ 追加 4.
credentials: true // ★ 追加 5.
},
/* 中略 */
}
modules
で@nuxtjs/dotenv
を読み込んでいれば基本的に.env
の内容は自動的に読み込まれますが、nuxt.config.js
の内部で設定値を扱いたい場合はこの一行が必要になります。axios
モジュールのためのプラグインです。内容は後述します。dotenv
モジュールを追加しています。axios
モジュールで Ajax 通信する際の基本 URL です。この設定をしておくと、まず API 呼び出しの際にアクセス先のパスのみ指定すればよくなります。また 4. のcredentials
の設定はbaseURL
に対して有効になるので、その意味でも必要です。- 異なるオリジンへのクッキーの送受信を有効にしています。
hosts ファイルの編集
フロントアプリが開発中もドメインを持てるように hosts
の設定を変更します。
127.0.0.1
の行の末尾に app.nuxt-auth-sample.test
を追記してください。
127.0.0.1 localhost app.nuxt-auth-sample.test
デフォルトでは開発中のアプリは localhost
で動作しますが、localhost
に対してはドメインを指定したクッキーを送信することができません。それでは重要な認証の動作を確認できないので、開発中もドメインを持てるように上記の変更を行います。
ドメイン名は要するに API とサブドメイン違いであればいいので、それぞれの環境や状況に合わせて決めてください。
プラグイン
axios
モジュールの挙動をカスタマイズするためのプラグインです。
export default function ({ $axios }) {
$axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
}
Laravel の AjaxOnly
ミドルウェアを通過するために、必ずリクエスト時に X-Requested-With
ヘッダーを付与する内容を記述します。
悪意のあるサイトを想定した場合、Ajax を利用するともちろん X-Requested-With
ヘッダーは偽装することができますが、Ajax リクエストであれば AjaxOnly
ミドルウェアを突破したとしても今度は CORS のオリジン制限が適用されるので結局アクセスできなくなるという戦法です。
ストア
ユーザーデータを管理するストアは以下の通りです。
通信エラーのフィードバックについては要件次第なので省略しています。
export const state = () => ({
user: null
})
export const mutations = {
setUser (state, user) {
state.user = user
}
}
export const actions = {
async login ({ commit }, { email, password }) {
const response = await this.$axios.$post('/login', { email, password })
.catch(err => err.response)
if (response.status !== 200) { /* TODO エラー処理 */ }
commit('setUser', response)
},
async logout ({ commit }) {
const response = await this.$axios.$post('/logout')
.catch(err => err.response)
if (response.status !== 200) { /* TODO エラー処理 */ }
commit('setUser', null)
}
}
上記のストアだけでも大丈夫そうに見えますが、ログインした後に画面をリロードするとストアからユーザー情報がクリアされるので、結果的に画面上はログアウトされてしまいます。そこで、以下の nuxtServerInit
アクションを追加します。サーバーサイドでレンダリングが発生したときにログインチェックを行い、戻ってきたユーザーデータをストアにセットします。
export const actions = {
// SSR が走るタイミングでログインチェック
async nuxtServerInit ({ commit }, { app }) {
await app.$axios.$get('/user')
.then(user => commit('auth/setUser', user))
.catch(() => commit('auth/setUser', null))
}
}
credentials
を true
と設定したので baseURL
へのアクセスにはクッキーが送信されます。そのため認証済みのクッキーを持っていればログイン中のユーザーデータが返ってくるはずです。これにより、ログイン後の画面の再訪やリロードでストアが構築しなおされたとしても、クッキーを持っていればフロント側でのログイン状態が継続する仕組みです。
ミドルウェア
画面遷移を制御するミドルウェアを作成します。
こちらは認証必須の画面に遷移する際のミドルウェアです。ユーザー情報を持っていなければログイン画面にリダイレクトさせます。
export default function ({ store, redirect }) {
if (! store.state.auth.user) {
redirect('/login')
}
}
上記とは逆に、ログイン画面など認証していない場合にだけアクセスできる画面のためのミドルウェアも作成します。ユーザー情報を持っていればトップページにリダイレクトさせます。
export default function ({ store, redirect }) {
if (store.state.auth.user) {
redirect('/')
}
}
ページ
最後にページを作っていきます。
まずはログインの有無にかかわらずアクセスできるトップページです。
<template>
<section>
<h1>Hello world.</h1>
<div v-if="$store.state.auth.user">
<nuxt-link to="/secret">secret</nuxt-link>
</div>
<div v-else>
<nuxt-link to="/login">login</nuxt-link>
</div>
</section>
</template>
次のログインページは middleware
に先ほど紹介した guest
を指定しているため、ログインしていない場合しかアクセスできません。
<template>
<section>
<h1>Login</h1>
<form @submit.prevent="submit">
<div>
<label for="email">email</label>
<input type="text" id="email" v-model="email" />
</div>
<div>
<label for="password">password</label>
<input type="password" id="password" v-model="password" />
</div>
<button type="submit">login</button>
</form>
</section>
</template>
<script>
export default {
middleware: 'guest',
data () {
return {
email: '',
password: ''
}
},
methods: {
async submit () {
await this.$store.dispatch('auth/login', {
email: this.email,
password: this.password
})
this.$router.push('/secret')
}
}
}
</script>
こちらはログイン後にのみアクセスできるページです。middleware
に auth
を指定しています。
<template>
<section>
<h1>Secret</h1>
<p v-if="message">{{ message.text }}</p>
<nuxt-link to="/">index</nuxt-link>
<hr />
<form @submit.prevent="logout">
<button type="submit">logout</button>
</form>
</section>
</template>
<script>
export default {
middleware: 'auth',
data () {
return {
message: null
}
},
async asyncData ({ app }) {
const message = await app.$axios.$get('/message')
return { message }
},
methods: {
async logout () {
await this.$store.dispatch('auth/logout')
this.$router.push('/')
}
}
}
</script>
以上、この記事では Nuxt アプリを API とクッキー認証で連携させるパターンについて紹介しました。Nuxt でアプリを構築する際の参考になればと思います。