2018.12.29

Universalモード(SSR)のNuxt.js + 異なるサブドメインのAPIという構成でクッキー認証とCORSを実現する


この記事では、サーバーサイドレンダリングする Universal モードの Nuxt.js フロントアプリと、それとは異なるサブドメインで運用される Web API という構成でクッキー認証とCORSを実現する方法を紹介します。

Nuxt でアプリケーションを構築する場合、サーバーサイド(というかデータ取得部分)は Web API になるでしょう。そして Web API での認証といえば JWT を用いたステートレスなトークン認証が一般的かと思います。しかし JWT を LocalStorage に格納する実装は脆弱なアンチパターンであると主張する記事や以下参考リンクの記事を読み、フロントエンド + Web API な構成でもクッキーで認証を行い CORS でクロスサイトなアクセスを防ぐ実装パターンを考えてみました。

今回の実装パターンは以下の記事を参考にしました。

Web API

今回はサーバーサイドの Web API は Laravel での構築例を紹介します。理由は本物の認証機能が簡単に用意できるからです。他の言語・フレームワークでも考え方は同様かと思います。

Laravel ユーザーではない、または単に Nuxt の構築例のみを知りたい方はこちらからどうぞ。

また、サンプルコードはこちらのリポジトリにも格納しています。

Laravel プロジェクトを作成する

まずは composer コマンドでプロジェクトを新規作成します。

$ composer create-project laravel/laravel nuxt-auth-sample

データをセットアップする

認証に必要なユーザーテーブルとテストデータを準備します。

今回はデモなので簡単のために SQLite を使用します。

.env
DB_CONNECTION=sqlite

データベースファイルを作成してマイグレーションを実行します。

$ touch database/database.sqlite
$ php artisan migrate

シーダーを作成してユーザーのテストデータを挿入します。

$ php artisan make:seeder UserTableSeeder
UserTableSeeder.php
<?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 に専念させる構成を採るため、ログイン・ログアウトそれぞれに成功した場合のレスポンスを変更します。

LoginController.php
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.phpauthenticated メソッドと 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 に設定ファイルが作成されるので、以下の内容に編集します。

cors.php
<?php

return [
    'supportsCredentials' => true, // ★
    'allowedOrigins' => [env('CORS_ALLOWED_ORIGIN')], // ★
    'allowedOriginsPatterns' => [],
    'allowedHeaders' => ['*'],
    'allowedMethods' => ['*'],
    'exposedHeaders' => [],
    'maxAge' => 0,
];

デフォルト(ファイル生成時)からの変更点は以下の2点(★)です。

  • クッキーの送受信を行うため、supportsCredentialstrue に設定します。
  • アクセスを許可するオリジン allowedOrigins.env の設定値を適用させます。

クロスオリジンのアクセスを許可するオリジンを .env に追記します。

.env
CORS_ALLOWED_ORIGIN=http://app.nuxt-auth-sample.test:3000

後ほど Nuxt 側を構築する際に設定しますが、フロントは上記の URL で動作させます。

ミドルウェアとルーティング

CORS が有効な API のためのミドルウェアグループとルーティングを新しく作成します。

まず app/Providers/RouteServiceProvider.php に、ルーティングファイルを読み込んでミドルウェアグループと紐づけるメソッドを追加します。

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 アクセスのみを許可するミドルウェアを新規作成します。

AjaxOnly.php
<?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 を追加します。

Kernel.php
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 を追加します。

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();
  1. ログインチェックのためのルート。現在のログインユーザを返す。ログインしていなければ返却値は null となる。
  2. 認証されたユーザーのみアクセスできるルート。
  3. 認証機能のルート。

クッキーの送信先

最後にクッキーの送信先を設定します。デフォルトでは同じドメインにしかクッキーは送信されないので、サブドメインも含めてクッキーを送信するように .env に記述を追加します。

.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 を記述します。

.env
API_URL=http://nuxt-auth-sample.test

nuxt.config.js に以下の記述を追加します。

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.
  },

  /* 中略 */
}
  1. modules@nuxtjs/dotenv を読み込んでいれば基本的に .env の内容は自動的に読み込まれますが、nuxt.config.js の内部で設定値を扱いたい場合はこの一行が必要になります。
  2. axios モジュールのためのプラグインです。内容は後述します。
  3. dotenv モジュールを追加しています。
  4. axios モジュールで Ajax 通信する際の基本 URL です。この設定をしておくと、まず API 呼び出しの際にアクセス先のパスのみ指定すればよくなります。また 4. の credentials の設定は baseURL に対して有効になるので、その意味でも必要です。
  5. 異なるオリジンへのクッキーの送受信を有効にしています。

hosts ファイルの編集

フロントアプリが開発中もドメインを持てるように hosts の設定を変更します。
127.0.0.1 の行の末尾に app.nuxt-auth-sample.test を追記してください。

hosts
127.0.0.1       localhost app.nuxt-auth-sample.test

デフォルトでは開発中のアプリは localhost で動作しますが、localhost に対してはドメインを指定したクッキーを送信することができません。それでは重要な認証の動作を確認できないので、開発中もドメインを持てるように上記の変更を行います。

ドメイン名は要するに API とサブドメイン違いであればいいので、それぞれの環境や状況に合わせて決めてください。

プラグイン

axios モジュールの挙動をカスタマイズするためのプラグインです。

axios.js
export default function ({ $axios }) {
  $axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
}

Laravel の AjaxOnly ミドルウェアを通過するために、必ずリクエスト時に X-Requested-With ヘッダーを付与する内容を記述します。

悪意のあるサイトを想定した場合、Ajax を利用するともちろん X-Requested-With ヘッダーは偽装することができますが、Ajax リクエストであれば AjaxOnly ミドルウェアを突破したとしても今度は CORS のオリジン制限が適用されるので結局アクセスできなくなるという戦法です。

ストア

ユーザーデータを管理するストアは以下の通りです。
通信エラーのフィードバックについては要件次第なので省略しています。

auth.js
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 アクションを追加します。サーバーサイドでレンダリングが発生したときにログインチェックを行い、戻ってきたユーザーデータをストアにセットします。

index.js
export const actions = {
  // SSR が走るタイミングでログインチェック
  async nuxtServerInit ({ commit }, { app }) {
    await app.$axios.$get('/user')
      .then(user => commit('auth/setUser', user))
      .catch(() => commit('auth/setUser', null))
  }
}

credentialstrue と設定したので baseURL へのアクセスにはクッキーが送信されます。そのため認証済みのクッキーを持っていればログイン中のユーザーデータが返ってくるはずです。これにより、ログイン後の画面の再訪やリロードでストアが構築しなおされたとしても、クッキーを持っていればフロント側でのログイン状態が継続する仕組みです。

ミドルウェア

画面遷移を制御するミドルウェアを作成します。

こちらは認証必須の画面に遷移する際のミドルウェアです。ユーザー情報を持っていなければログイン画面にリダイレクトさせます。

auth.js
export default function ({ store, redirect }) {
  if (! store.state.auth.user) {
    redirect('/login')
  }
}

上記とは逆に、ログイン画面など認証していない場合にだけアクセスできる画面のためのミドルウェアも作成します。ユーザー情報を持っていればトップページにリダイレクトさせます。

guest.js
export default function ({ store, redirect }) {
  if (store.state.auth.user) {
    redirect('/')
  }
}

ページ

最後にページを作っていきます。

まずはログインの有無にかかわらずアクセスできるトップページです。

index.vue
<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 を指定しているため、ログインしていない場合しかアクセスできません。

login.vue
<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>

こちらはログイン後にのみアクセスできるページです。middlewareauth を指定しています。

secret.vue
<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 でアプリを構築する際の参考になればと思います。