LaravelとVueで構成したアプリにOAuth2.0 PKCE 認証をぶち込む

LaravelとVueで構成したアプリにOAuth2.0 PKCE 認証をぶち込む

Laravel + Vue.jsの構成にOAuth2.0 PKCE認証を追加する方法です。
Vue.jsは完全に独立したSPAとして作り、Laravel(バックエンド)に対してはAPIで通信するだけの関係の例となります。

OAuth2.0 PKCE認証とは

Yahooの中の人が書いて下さっている記事が一番分かりやすいので紹介します。

OAuth2.0拡張仕様のPKCE実装紹介 〜 Yahoo! ID連携に導入しました
https://techblog.yahoo.co.jp/entry/20191219790463/

Laravel Passportをインストールする

LaravelでOAuth認証を実装するには、公式からも推奨されているLaravel Passportパッケージをインストールします。

参考:https://readouble.com/laravel/8.x/ja/passport.html

composer require laravel/passport

マイグレーションを実行する

Passportインストール後にマイグレーションを実行し、関連するテーブルを追加します。
マイグレーションファイルはvendorディレクトリ下のPassportの実体がある場所に入っています。

php artisan migrate

暗号化キーを生成する

アクセストークンを生成するために暗号化キーが必要になります。
サーバーセットアップ時に1回だけ以下のコマンドからキーを生成します。
作成されたキーはデフォルトで /storage/ ディレクトリの中に保存されます。

php artisan passport:keys

ルーティングを追加する

Passportで必要なルーティングを追加します。

App\Providers\AuthServiceProvider

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Laravel\Passport\Passport;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * アプリケーションのポリシーのマップ
     *
     * @var array
     */
    protected $policies = [
        'App\Models\Model' => 'App\Policies\ModelPolicy',
    ];

    /**
     * 全認証/認可サービスの登録
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        if (! $this->app->routesAreCached()) {
            Passport::routes();
        }
    }
}

 

クライアントを作成する

これまでの手順は標準的なLaravel Passportのセットアップ手順です。
ここからはOAuth2.0 PKCE認証のセットアップ手順になります。

https://readouble.com/laravel/8.x/ja/passport.html

認証対象のクライアントを作成します。
これは利用ユーザー単位ではなく、SPA、モバイルアプリ、外部連携先、などのバックエンドから見たクライアントの種別単位になります。

php artisan passport:client --public

認可サーバー(Laravel)が認可を終えてトークンレスポンスの送信先(リダイレクトURL)の入力を求められます。
今回の例では

https://localhost/frontend/authenticate/auth

となります。

フロントエンドを実装する

Vue.jsでフロントエンドを実装していきます。

js-cookieライブラリをインストールする

取得したトークンをクッキーに保存するため、クッキーに読み書きするライブラリを利用します。
JavaScriptで自分でクッキーに書き込んでも良いですが、レガシーなインターフェースで有効期限等のオプションを書き込もうと思うと面倒なので使ったほうが楽です。

npm i js-cookie

js-pkceライブラリをインストールする

PKCE認証はクライアント側で暗号化・復号化のために code_verifiercode_challenge を生成する必要があります。
自分で生成するコードを書いても良いですが、誰が書いても同じでライブラリが存在しているので利用します。
自分で書くときはランダム性を持たせてキー生成する必要があるので注意が必要です。

js-pkce – npm
https://www.npmjs.com/package/js-pkce

npm i js-pkce

routeを定義する

認証に必要なcode_veriferを生成する(preparation)と、
認証後に認可サーバーからトークンがリダイレクトで叩かれてくるURLを定義します。

<template>
import Vue from 'vue';
import VueRouter from 'vue-router';

Vue.use(VueRouter);

const routes = [
  {
    path: '/authenticate/preparation',
    name: 'Preparation',
    meta: { title: '' }, // 認証用code_verifer生成・転送
    component: () => import('../views/Authenticate/Preparation.vue'),
  },
  {
    path: '/authenticate/auth',
    name: 'Auth',
    meta: { title: '認証' },
    component: () => import('../views/Authenticate/Auth.vue'),
  },
];

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

認可リクエストページを作成する

code_challengeを生成して認可リクエストを送信する画面を作成します。

Webページのコンテンツは何もなく、マウントが完了したらcode_challengeを生成して認可サーバーへリクエストを送信します。

frontend/src/views/Authenticate/Preparation.vue

<template>
  <div />
</template>

<script>
import PKCE from 'js-pkce';

export default {
  name: 'Preparation',
  mounted() {
    const pkce = new PKCE({
      client_id: '1', // oauth_clientsテーブルに追加されているクライアントのID
      redirect_uri: 'https://localhost/frontend/authenticate/auth', // oauth_clientsテーブルに追加されているredirect
      authorization_endpoint: 'https://localhost/backend/oauth/authorize', // Laravel Passportが定義する認証エンドポイントのルート
      requested_scopes: '*',
    });
    // 認証URLへ遷移する
    location.replace(pkce.authorizeUrl());
  },
};
</script>

<style></style>

Laravel Passportで具体的にどんなルートが追加されているかは、 ルートリストを表示するArtisanコマンドで確認できます。

php artisan route:list

認証ページを作成する

ID・パスワードで認証する画面です。

認証画面はバックエンド側で用意するため、Laravelのbladeで作成します。
自社SPAのためのバックエンドの場合でも、GoogleやTwitterで外部認証する場合に認証画面は認証先が提供するように認可サーバーが用意します。
(違ってたら教えて下さい)

backend/resources/views/login.blade.php

<form method="post" action="login">
    @csrf

    <label for="email">Email:</label>
    <input type="text" name="email">

    <label for="password">Password:</label>
    <input type="password" name="password">

    <button>Login</button>
</form>

 

トークンリクエストを受け取るページを作成する

認証後に認可サーバーからトークンがリダイレクトされるページを作成します。

src/views/Authenticate/Auth.vue

<template>
  <div>
    <h1>認証中...</h1>
  </div>
</template>

<script>
import PKCE from 'js-pkce';
import Cookies from 'js-cookie';

export default {
  mounted() {
    const pkce = new PKCE({
      client_id: '1',
      redirect_uri: 'https://localhost/frontend/authenticate/auth',
      token_endpoint: 'oauth/token',
    });
    pkce.exchangeForAccessToken(document.location.href).then(response => {
      // アクセストークンをセット
      const expiresMinutes = 30;
      Cookies.set('ACCESS_TOKEN_KEY', response.access_token, {
        expires: new Date(new Date().getTime() + expiresMinutes * 60 * 1000),
        sameSite: 'lax',
      });
      
      // リフレッシュトークンをセット
      const expiresDays = 5;
      Cookies.set('REFRESH_TOKEN_KEY', response.refresh_token, {
        expires: expiresDays,
        sameSite: 'lax',
      });

      // 認証後に遷移するページへ
      this.$router.replace({ name: 'Dashboard' });
    });
  },
};
</script>

<style></style>

 

axiosのリクエストヘッダーに追加する

axiosでHTTPリクエストする時にヘッダーにトークンをセットします。
axiosを直接呼び出さず、HttpClientクラスとしてラップして通信することでトークンをセット処理を共通化しています。

Vueのプラグイン機能でaxiosを管理し、シングルトンとして扱い、初回にトークンをセットしてもOKです。

import router from '../router';

class HttpClient {
  constructor() {
    const config = {
      baseURL: process.env.VUE_APP_API_URL,
    };

    this.client = axios.create(config);
    this.client.interceptors.request.use(this.setAccessToken);
  }

  /**
   * アクセストークンをリクエストヘッダーにセット
   * @param {object} config
   * @returns {object}
   */
  setAccessToken = async config => {
    const accessToken = Cookies.get(ACCESS_TOKEN_KEY);
    config.headers.Authorization = `Bearer ${accessToken}`;

    return config;
  };
  
    /**
   * GETリクエスト
   * @param {string} url
   * @param {object} config
   * @returns {Promise}
   */
  get = async (url, config = {}) => {
    return await this.client.get(url, config);
  };

  /**
   * POSTリクエスト
   * @param {string} url
   * @param {any} data
   * @param {object} config
   * @returns {Promise}
   */
  post = async (url, data = undefined, config = {}) => {
    return await this.client.post(url, data, config);
  };

  /**
   * PUTリクエスト
   * @param {string} url
   * @param {any} data
   * @param {object} config
   * @returns {Promise}
   */
  put = async (url, data = undefined, config = {}) => {
    return await this.client.put(url, data, config);
  };

  /**
   * DELETEリクエスト
   * @param {string} url
   * @param {object} config
   * @returns {Promise}
   */
  delete = async (url, config = {}) => {
    return await this.client.delete(url, config);
  };
}

const httpClientAuth = new HttpClientAuth();

export { httpClientAuth, HttpClientAuth };

プログラミングカテゴリの最新記事