NuxtとLaravel Sanctumで認証し、ログインページへの直アクセスにも対策する

..

前提条件

  • SSRを想定しています。
  • Nuxt は2系です。
  • JWTではなくセッションIDをCookieに保存するタイプの認証を行います。
  • Laravel Sanctum と @nuxtjs/auth-next を使用する方法です。

ローカルのDocker環境で、Laravel SanctumとNuxtのAuthモジュールを使って認証を行うまでの手順をまとめます。

実際の設定内容

.env
NODE_ENV='development'
AUTH_SCHEME='local'
AXIOS_HTTPS=false
NODE_URL=http://0.0.0.0:4001
SERVER_ORIGIN=http://<Nginxコンテナ名>:80
nuxt.config.js
export default {
  /*
   * 今回の記事に関係してる部分だけ
   */
  
  ssr: true,

  axios: {
    prefix: '/api/',
    https: process.env.AXIOS_HTTPS,
    proxy: true,
    credentials: true,
  },
  proxy: {
    '/laravel': {
      target: process.env.NODE_URL,
      pathRewrite: { '^/laravel': '/' }
    },
    '/api/login': { 
      target: process.env.SERVER_ORIGIN, 
      pathRewrite: {'^/api/': '/'} 
    },
    '/api/': { 
      target: process.env.SERVER_ORIGIN, 
    },
  },
  publicRuntimeConfig: {
    AUTH_SCHEME: process.env.AUTH_SCHEME,
    NODE_ENV: process.env.NODE_ENV
  },

  auth: {
    redirect: {
      home: '/',
      login: '/login',
      logout: '/',
      callback: false,
    },
    strategies: {
      local: {
        token: {
          required: false,
          type: false
        },
        endpoints: {
          login: { url: `/login`, method: 'post' },
          logout: false,
          user: false
        }
      },
      laravelSanctum: {
        provider: 'laravel/sanctum',
        url: process.env.SERVER_ORIGIN,
        cookie: {
          name: 'XSRF-TOKEN',
        },
        endpoints: {
          csrf: {
            url: '/sanctum/csrf-cookie'
          },
          login: { url: '/login', method: 'post' },
          logout: false,
        }
      }
    },
  },
  router: {
    middleware: ['auth'],
  },

  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [
    '@nuxtjs/axios',
    '@nuxtjs/auth-next',
  ],

  // Build Configuration: https://go.nuxtjs.dev/config-build
  build: {
  }
}

index.js
export const actions = {
  async nuxtServerInit({state}, { app }) {

    await app.$axios
      .$get('/user')
      .then((authUser) => {
        app.$auth.setUser(authUser)
      })
      .catch((err) => {
        app.$auth.setUser(null)
      })

  }
}

認証方法の違いについて

JWTトークン認証は、クライアントごとにユニークなトークン文字列を持っておくことでそれを鍵にしようという方法です。REST原則においてはサーバーとクライアントは状態を持ちませんので、サーバーも認証においてセッション情報を保持したりしません。

しかしながらセッション認証と比較した場合にJWTトークンはあまりセキュアではないという懸念があることで、認証状態についてはステートフルじゃなくていいんでねえの、という雰囲気になってもいるようです。Laravel Sanctumはどちらの認証方法もサポートしているものの、公式にはセッション認証を推奨しています。

Authモジュールについて

既存の記事では、

  • axiosでPOSTしてログイン
  • nuxtServerInitメソッド内でユーザー情報を取得(認証ガード付きAPIへGET)してstoreへ格納
  • middlewareでstoreを参照
  • storeにユーザー情報があれば認証済み、なければ未認証としてログインページへリダイレクト

という流れを自前実装しているものも多いです。

しかし、これらの大部分はNuxtが公式にサポートしているAuthモジュール側で実装してくれているようだったので、とりあえずそれをインストールしてあとは細かい部分を自分で入れればどうにかなりました。

Laravel側の設定

CORSを許可しないといけません。

例えば筆者の環境では、Laravelコンテナはhttp://localhost:9000で動作し、リクエスト自体はNginxが動いているコンテナhttp://localhost:8080が処理しています。リクエストを送るクライアントであるNuxtコンテナはhttp://localhost:4001で動作しているため、この場合はlocaihost:4001localhost:9000の作成・返却したレスポンスを参照・使用することを許可する必要があります。

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Cross-Origin Resource Sharing (CORS) Configuration
    |--------------------------------------------------------------------------
    |
    | Here you may configure your settings for cross-origin resource sharing
    | or "CORS". This determines what cross-origin operations may execute
    | in web browsers. You are free to adjust these settings as needed.
    |
    | To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    |
    */

    'paths' => [
        'api/*', 
        'login',
        'logout',
        'sanctum/csrf-cookie',
    ],

    'allowed_methods' => ['*'],

    'allowed_origins' => [env('FRONT_ORIGIN')],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,

    'supports_credentials' => true,

];

.env内にFRONT_ORIGIN=http://0.0.0.0:4001を追加し、その環境変数をconfig/cors.phpで呼び出します。

Sanctumの設定

普通に公式ページや他の記事でも書いてあることをするだけなので、読み飛ばしてもいいところです。

app/Http/Kernel.php
         'api' => [
             \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
             'throttle:api',
             \Illuminate\Routing\Middleware\SubstituteBindings::class,
         ],
.env
SESSION_DRIVER=cookie
SESSION_DOMAIN=0.0.0.0

SANCTUM_STATEFUL_DOMAINS=0.0.0.0:4001
config/cors.php
    'paths' => [
        'api/*', 
        'login',
        'logout',
        'sanctum/csrf-cookie',
    ],

    //略

    'supports_credentials' => true,

ログイン処理を追加

ここは各自いろいろ書き方のある部分かと思います。(メールアドレスの代わりにユーザーIDを使いたいとか)
とりあえずやるべきことは、ログインの場合はattemptメソッドで入力値をデータベースと照合することとセッションを生成すること、ログアウトの場合はセッションを破棄することです。

LoginController.php

<?php

// 略

// とりあえず動作させるためhttps://qiita.com/ucan-lab/items/3e7045e49658763a9566からコピペしたソースです。
// 実際はAuthファサードは直に呼ばず、リポジトリクラスを追加してそこにAuthを使いたいメソッドを切り出したりします。
// バリデーションもRequestクラスを別途作成して行うなどする予定です。

class LoginController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private AuthManager $auth,
    ) {
    }

    /**
     * @Method POST
     * @param  Request  $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function login(Request $request): JsonResponse
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required'
        ]);
        
        if (auth()->attempt($credentials)) {
            $request->session()->regenerate();

            return response()->json(Auth::user());
        }

        return response()->json(['message' => 'ユーザーが見つかりません。'], 422);
    }

    /**
     * Undocumented function
     *
     * @param Request $request
     * @return JsonResponse
     */
    public function logout(Request $request): JsonResponse
    {
        if ($this->auth->guard()->guest()) {
            return new JsonResponse([
                'message' => 'Already Unauthenticated.',
            ]);
        }
        
        $this->auth->guard()->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return new JsonResponse([
            'message' => 'Unauthenticated.',
        ]);
    }
}

ログインルートは、基本的にはweb.php内に書くようです。はっきりとした理由はドキュメントを読んでもよくわかりませんでしたけど、

通常、SanctumはLaravelの「web」認証ガードを利用してこれを実現します。

とあるので、webルート用に実装されてるミドルウェアを呼ぶためなのかな?とは思います。

実際Sanctumが何をするかというと、SANCTUM_STATEFULL_DOMAINに書かれたドメインに限ってセッションをステートフルにすること、CSRFトークンを使ってCORSを許可しているサイトかどうかを識別すること、セッションに認証情報がなければモバイルトークンの有無を見るなどで、ルートに対するガードを行う機能はありません。

routes/web.php
  Route::post('/login', [LoginController::class, 'login']);
  Route::post('/logout', [LoginController::class, 'logout']);

Nuxt側の設定

ふたつのモジュールをインストールする必要があります。

axios

  modules: [
    '@nuxtjs/axios',
  ],

インストールしたこれらをmodulesにて呼び出します。
これによりNuxtアプリケーション自体に彼らが組み込まれるため、インポートせずともコンポーネント内であればthis.$axiosとか、nuxtServerInit({ commit }, { app })の中であればapp.$authとかの記述で使えるようになります。

  axios: {
    prefix: '/api/',
    proxy: true,
    credentials: true,
  },
  proxy: {
    '/laravel': {
      target: process.env.NODE_URL,
      pathRewrite: { '^/laravel': '/' }
    },
    '/api/login': { 
      target: process.env.SERVER_ORIGIN, 
      pathRewrite: {'^/api/': '/'} 
    },
    '/api/': { 
      target: process.env.SERVER_ORIGIN, 
    },
  },

axiosの設定はこんな感じにしてます。SERVER_ORIGINはnginxコンテナの、NODE_URLはNuxtコンテナのオリジンを格納している環境変数です。

設定値の解説(axiosのドキュメントをかいつまんで引用)はこんな感じ。

  • prefixproxytrueにする場合のbaseURLの代わり。後述するRuntimeConfigが読み込まれていない場合のデフォルト値。
  • credentialstrueにする。異なるドメイン間でCookie等の資格情報をやりとりするために必要。

他には下にある配列でプロキシ設定を行うわけですが、これは、ブラウザからXMLHttpRequestによって送信されたリクエストをNuxt(Node.js)側で受けたあと、外部サーバーへそのリクエストを転送する際にリクエスト先を指定するための設定だと理解しています。

たとえば上記の例では$axios.<なんらかのHTTPメソッド>('/api')は、この設定により$axios.<なんらかのHTTPメソッド>(http://0.0.0.0:8080/api)へのリクエストに書き換わります。

  proxy: {
    '/laravel': {
      target: process.env.NODE_URL,
      pathRewrite: { '^/laravel': '/' }
    },
    '/api/login': { 
      target: process.env.SERVER_ORIGIN, 
      pathRewrite: {'^/api/': '/'} 
    },
    '/api/': { 
      target: process.env.SERVER_ORIGIN, 
    },
  },

▲この部分、なぜ/api//api/login/の両方にプロキシを設定しているかというと、Laravel側ではAPIリクエストは/api〜で受けますが、認証系については/loginで受けるようにしているからです。こういう処理になります。

  1. /loginへリクエストする
  2. prefix: '/api/'を設定しているので/api/loginへのリクエストに書き換わる
  3. pathRewrite: {'^/api/': '/'}prefixを消すので再度/loginへ戻る

どんな値を指定してもなかなか Nuxt から Laravel へリクエストを届けられなかったのですが、なにかの記事で「オリジンはコンテナ名で指定する必要がある」ということを知り、SERVER_ORIGINにはhttp://<Nginxコンテナの名前>:80を入力。http://<Nginxコンテナの名前>:80/api/<ルート名>へリクエストするようにしたらうまくいきました。

コンテナ名はdocker compose psを実行すると確認できます。

auth

nuxt.config.js
  modules: [
    '@nuxtjs/axios',
    '@nuxtjs/auth-next', // 同様に追加
  ],

authモジュールがやってくれるのは、例えばこんなことです。

  • ログイン中かどうかを真偽値で返してくれる($auth.loggedIn)
  • ログインしたユーザーをストア(auth.jsがエディタからは見えないけどある)へ格納してくれる
  • 認証方法のパターンをいくつか指定できる(Laravel Sanctum とか Google とか)

axios単体で認証機能を実装しようとしたら色々と自前で実装する必要が出てきますが、authモジュールはそのへんの需要を諸々満たしてくれてます。

nuxt.config.js
  auth: {
    redirect: {
      login: '/login', 
      logout: '/', 
      callback: '/',
      home: '/'
    },
    strategies: {
      laravelSanctum: {
        provider: 'laravel/sanctum',
        url: process.env.SERVER_ORIGIN,
        cookie: {
          name: 'XSRF-TOKEN',
        },
        endpoints: {
          csrf: {
            url: '/sanctum/csrf-cookie'
          },
          login: { url: '/login', method: 'post' },
          logout: { url: '/logout', method: 'post' },
        }
      }
    }
  },

authモジュールは内部的にaxiosモジュールを使用してリクエストを飛ばすため先程の設定が必要でした。
それに加え、auth: {}の中にauthモジュール側の設定を色々書いていきます。

redirectの各設定値はこんな感じです。

  • login (ログインが必要な場合のリダイレクト先URL)
  • logout (ログアウトしたあとのリダイレクト先URL)
  • callback (ログイン終了後、GoogleなどのIDプロバイダがリダイレクトするURL)
  • home (ログイン終了後、Nuxt内でリダイレクトされるURL)

ログインを求めたいページへ未認証でアクセスされた場合にログインページへリダイレクトする、ログインできたらトップページへリダイレクトする…といった処理は、全てこのオブジェクトを配置すれば実現できます。

strategiesは、認証方法それぞれで個別に使用する設定をごにょごにょ書く場所です。今回はLaravel Sanctumを使ってセッションに状態をもたせる形の認証を行うため、/sanctum/csrf-cookieへGETリクエストを送ってXSRFトークンをもらう設定とか、/login/logoutへPOSTする際にCookie内のXSRFトークンを参照する設定とかを書きます。

あとはこんなふうにログインページのコンポーネントを作れば…

<template>
  <div>
    <v-form>
      <v-text-field 
        label="ログインID" v-model="loginId"
      />
      <v-text-field 
        label="パスワード" 
        v-model="password"
        @click:append="showPassword = !showPassword" 
        v-bind:type="showPassword ? 'text' : 'password'"
        append-icon="mdi-eye-off" 
      />
    </v-form>
    <div>
      <v-btn @click="login" depressed>ログイン</v-btn>
    </div>
  </div>
</template>

<script>
export default {
  data: () => ({
    showPassword: false,
    loginId: '',
    password: '',
  }),
  methods: {
    async login() {
      await this.$auth.loginWith(this.$config.AUTH_SCHEME, {
        data: { 
          loginId: this.loginId, 
          password: this.password 
        }
      })
      .then((res) => {
        
      })
      .catch(err => {

      });
    }
  }
}
</script>

ログイン機能は出来上がりです。

ログアウトも、Laravel側でログアウトの処理を書いていれば、あとはこんなふうに作れます。

<template>
  <button @click="logout">
    ログアウト
  </button>
</template>

<script>
export default {
  methods: {
    async logout() {
      await this.$auth.logout();
    }
  }
}
</script>

nuxtServerInitでフェッチを行う

index.js
export const actions = {
  async nuxtServerInit({ commit }, { app }) {
    
    await app.$axios
      .$get('/api/user')
      .then((authUser) => {
        app.$auth.setUser(authUser)
      })
      .catch((err) => {
        app.$auth.setUser(null)
      })

  }
}

セッションが継続している(ログイン中である)場合は、authuserにユーザーの情報が入ってくるので、この時点でstoreに格納してしまいます。もしセッションが切れている場合は401 Unauthorizedが返ってくるので、その例外をキャッチしてstoreの中身をリセットします。

以上の実装を行うことで、ログインした後はログアウトするまでログインページへアクセスできなくなります。
一般的な感覚では、ログイン中にログインページへのリンクは表示されないようv-if等で出し分けるとは思いますが、仮に NuxtLink が残ってしまっていても、あるいはアドレスバーから直にURLを叩かれても、有効なセッションがあればホームへリダイレクトされます。

ログアウトについて

nuxt.config.js
  auth: {
    redirect: {
      login: '/login', 
      logout: '/', 
      callback: '/',
      home: '/'
    },
    strategies: {
      laravelSanctum: {
        provider: 'laravel/sanctum',
        url: process.env.SERVER_ORIGIN,
        cookie: {
          name: 'XSRF-TOKEN',
        },
        endpoints: {
          csrf: {
            url: '/sanctum/csrf-cookie'
          },
          login: { url: '/login', method: 'post' },
          logout: false,
        }
      }
    }
  },

今回はサーバーサイド側にもログアウトAPIを実装しましたが、設定でlogout: falseを記述すると、APIへのリクエストはなしにCookieやJWTトークンの削除を行うようです。それだけで有効な認証状態はなくなるため、もしログアウト時にサーバーサイドでやりたいことがなければ、エンドポイントそのものが不要であると考えてもよさそうです。


参考記事リンク