認証付きGraphQL APIサーバーを爆速で立てる。 Hasura + Firebase Authentication

HasuraはPostgreSQLからGraphQL APIサーバーを爆速で構築できるものの、認証については外部の認証基盤を使う必要があります。
今回は、認証基盤としてFirebase AuthenticationのJWT認証を使った例を紹介します。

Hasuraの認証について

Hasuraの認証はWebhook方式と、JWT方式があり、今回はJWT方式を使います。
JWTは属性情報をJSONデータ構造で表現したトークンを使い認証を行う方法で、Firebase Authenticationにて採用されています。
Hasuraの認証でFirebase Authenticationを使う場合は以下のような流れとなります。

  1. クライアントアプリでFirebase SDKを利用して認証サーバーにアクセスしてトークンを発行(ここでカスタムクレームにHasuraの属性情報も持たせておく)
  2. リクエストヘッダーのAuthorizationにBearerスキームで、トークンを埋め込む
  3. Hasuraから検証サーバーにアクセスしてトークンの検証を行う。

無題のプレゼンテーション.jpg

Hasuraの設定

まずGraphQL APIサーバーの構築を行います。

Hasura環境構築

以下のボタンをクリックしてHerokuにHasuraをデプロイします。

image.png

アプリ名は適当に入力してDeploy App。

スクリーンショット 2020-01-12 11.41.51.png

完了したらView appからHasuraのコンソールへ。

スクリーンショット 2020-01-12 11.42.36.png

これでHasuraの環境構築は完了です。
上部に表示されているアクセスポイントでHasuraのGraphQL APIが使用できます。

スクリーンショット 2020-01-12 11.44.45.png

アクセス制限、JWT検証サーバーの設定

Hasuraのコンソール・APIは今のままだと、URLさえ知れば誰でもアクセスできる状態です。それを防ぐためADMIN_SECRETの設定をします。

Herokuのコンソールより、Hasura用に作成したAPPを選択してSettingsのConfig Varsから以下を設定してください。

変数名
HASURA_GRAPHQL_ADMIN_SECRET Admin権限でアクセスする際に必要なパスワード(任意の値)
HASURA_GRAPHQL_JWT_SECRET JWTのモード、検証サーバーの設定(こちらより生成)

スクリーンショット 2020-01-12 23.43.18.png

HASURA_GRAPHQL_JWT_SECRETは、https://hasura.io/jwt-config よりFirebase、Project IDを入力することで生成できます。

スクリーンショット 2020-01-12 23.43.18.png

これでHasura側の設定は完了です。
以降、Hasuraのコンソールにログインする際、パスワードを求められるようになるはずです。
HASURA_GRAPHQL_ADMIN_SECRETで設定したパスワードを入力してログインしてください。

スクリーンショット 2020-01-12 23.53.17.png

テーブル構築

今回は簡易なメモアプリを作成します。テーブルはユーザー情報を保存するusersと、メモを保存するmemosの2つとします。
以下Hasuraのコンソール上でテーブルを作成します。
上部メニューDATA > Create tableより、まずusersテーブルの作成。

カラム名 構造 属性
id Text Primary key
name Text
create_at TimeStamp default now()

スクリーンショット 2020-01-12 12.00.20.png

次にmemosテーブルの作成。

カラム名 構造 属性
id Integer(Auto-increment) Primary key
user_id Text Foreign key user.id
content Text
created_at Timestamp default now()

スクリーンショット 2020-01-13 20.35.51.png

Foreign Keysでuser_idの関連キーとしてusersテーブルのidを指定します。

スクリーンショット 2020-01-13 20.35.35.png

これでテーブルの作成は完了です。

認可の設定

次にテーブルの操作・カラム単位での認可の設定をします。
DATA > サイドメニュー todos > Permissionsタブから新しいロールuserを追加します。
そしてuserロールでは自分のuser_idと関連するメモしかinsert, select, update, delete出来ないようにします。

ここで設定するX-Hasura-User-Idは後ほどFirebase Authenticationのカスタムクレームで設定します。

まずinsertの設定。
Allow role user to insert rowsで、With custom checkを選択肢し、user_id eq X-Hasura-User-Idを指定します。
その他、画像のように設定します。

スクリーンショット 2020-01-13 20.37.06.png

次にselectの設定。
Allow role user to insert rowsは、With same custom check insertを選択します。
これでinsertと同じくuser_idがX-Hasura-User-Idと同様のものしかselect出来ないようになります。
ほかは画像のとおりです。

スクリーンショ<img width=

次にupdateの設定。
同じくAllow role user to insert rowsは、With same custom check insertを選択肢します。
ほかは画像のとおりです。

スクリーンショット 2020-01-13 20.38.01.png

最後にdeleteの設定。
同じくAllow role user to insert rowsは、With same custom check insertを選択肢します。

スクリーンショット 2020-01-13 20.38.12.png

これで認可の設定は完了です。

Firebaseの設定

続いて認証基盤として使うFirebaseの設定です。

プロジェクトの作成

任意のディレクトリでFirebaseプロジェクトを作成してください。使うリソースはCloud FunctionsとFirestoreです。
拙著ですが、TypeScriptでESLint+Prettierを使う場合の設定例はこちらをどうぞ。

firebase init

課金プランの設定

Cloud FunctionsからHasuraサーバーへユーザー作成のリクエストを投げため、外部サービスへのアクセスを有効にする必要があります。
initで設定したFirebaseプロジェクトのコンソールにログインして、課金プランをBlazeプランに変更してください。

スクリーンショット 2020-01-13 7.11.57.png

Firebase Authenticationの設定

Firebase Authenticationを有効化します。
コンソールのサイドメニューよりAuthenticationを選択して、任意のログイン方法を有効化してください(デモだとGoogleとEmailログイン)。

スクリーンショット 2020-01-13 7.20.34.png

Cloud Functionsの設定

最後にFirebase Cloud Functionsです。
Firebase Authenticationでのユーザー追加をフックに起動するFunctionsを設定します。
firebase initしたディレクトリのfunctions以下で依存モジュールを追加します。

npm i apollo-boost graphql graphql-tag node-fetch @types/node-fetch

次にコード内で参照する環境変数として、HasuraのエンドポイントURLとadmin_secreteを設定します。

firebase functions:config:set hasura.url="herokuのHasuraエンドポイントURL" hasura.admin_secret="HerokuのConfig varsで指定したHASURA_GRAPHQL_ADMIN_SECRETの値"

今回functionsのAuthenticationユーザー追加のトリガーにて以下を行います。

  1. Hasura認証用のカスタムクレームの設定
  2. Hasuraサーバーへのユーザー作成リクエストの送信
  3. tokenリフレッシュのフック用にFirestoreへのmetaデータ追加

functions/index.tsに以下を記載してください。

functions/index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import ApolloClient from "apollo-boost";
import fetch from "node-fetch";
import gql from "graphql-tag";

admin.initializeApp();

const client = new ApolloClient({
  uri: functions.config().hasura.url,
  fetch: fetch as any,
  request: (operation): void => {
    operation.setContext({
      headers: {
        "x-hasura-admin-secret": functions.config().hasura.admin_secret
      }
    });
  }
});

export const setCustomClaims = functions.auth.user().onCreate(async user => {
 // Hasuraの検証用のカスタムクレーム(属性情報)
  const customClaims = {
    "https://hasura.io/jwt/claims": {
      "x-hasura-default-role": "user",
      "x-hasura-allowed-roles": ["user"],
      "x-hasura-user-id": user.uid
    }
  };

  try {
    // カスタムクレームの設定
    await admin.auth().setCustomUserClaims(user.uid, customClaims);

    // Hasuraサーバーへのユーザーデータの作成リクエスト
    await client.mutate({
      variables: { id: user.uid, name: user.displayName || "unknown" },
      mutation: gql`
        mutation InsertUsers($id: String, $name: String) {
          insert_users(objects: { id: $id, name: $name }) {
            returning {
              id
              name
              created_at
            }
          }
        }
      `
    });

    // 初回ログインの際にユーザー作成と、カスタムクレームの設定には遅延があるため、
    // tokenリフレッシュのフック用にFirestoreへのmetaデータ追加を行う
    await admin
      .firestore()
      .collection("user_meta")
      .doc(user.uid)
      .create({
        refreshTime: admin.firestore.FieldValue.serverTimestamp()
      });
  } catch (e) {
    console.log(e);
  }
});

これでdeployを実行するとfunctionsが作成されます。

npm run deploy

deploy完了後、コンソールにてFunctionsを確認できればOKです。

スクリーンショット 2020-01-14 5.36.43.png

クライアントの実装

最後にVue.jsでのクライントの実装を紹介します。
今回は認証がメインなので、細かい環境構築等は省き関連コードのみ紹介します。
以下で紹介するメモアプリの動作コードはこちらにあります。

https://github.com/kawamataryo/Hasura-with-firebaase-auth-tutorial

Firebase Authenticationのログインフックの設定

main.tsにて、Vueの初期化の前にFirebase Authenticationのログインフックの設定を行っています。
ログイン後Hasuraのカスタムクレームがない場合は、FirestoreのonSnapshotにてuser_metaの変更を待ち、変更後再度tokenの取得、ログイン処理を行っている点がポイントです。

main.ts
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import vuetify from "./plugins/vuetify";
import { apolloProvider, onLogin, onLogout } from "@/plugins/apollo";
import VueApollo from "vue-apollo";
import { auth, db } from "@/plugins/firebase";

const HASURA_TOKEN_KEY = "https://hasura.io/jwt/claims";

Vue.use(VueApollo);

Vue.config.productionTip = false;

let vue: Vue;
// firebaseの初期化が終わったあとにVueを初期化するようにする
auth.onAuthStateChanged(async user => {
  if (!vue) {
    new Vue({
      vuetify,
      apolloProvider,
      router,
      render: h => h(App)
    }).$mount("#app");
  }
  if (user) {
    const token = await user.getIdToken(true);
    const idTokenResult = await user.getIdTokenResult();
    const hasuraClaims = idTokenResult.claims[HASURA_TOKEN_KEY];

    if (hasuraClaims) {
      await onLogin(token);
    } else {
      // Tokenのリフレッシュを検知するためにコールバックを設定する
      const userRef = db.collection("user_meta").doc(user.uid);
      userRef.onSnapshot(async () => {
        const token = await user.getIdToken(true);
        await onLogin(token);
      });
    }
  } else {
    await onLogout();
  }
});

Apolloクライアントの設定、ログイン・ログアウト処理

ApolloクライアントではBearerスキームで使うtokenをLocalStorage経由で設定しています。
ログイン、ログアウト処理では、tokenのLocalStorageへの追加・削除と、Apolloクライアントのリフレッシュを行っています。

plugin/apollo.ts
import ApolloClient from "apollo-boost";
import VueApollo from "vue-apollo";

const AUTH_TOKEN = "hasura-auth-token";

const client = new ApolloClient({
  uri: process.env.VUE_APP_GRPHQL_HTTP,
  request: operation => {
    operation.setContext({
      headers: {
        Authorization: `Bearer ${localStorage.getItem(AUTH_TOKEN)}`
      }
    });
  }
});

// ログイン処理
export async function onLogin(token: string) {
  if (localStorage.getItem(AUTH_TOKEN) !== token) {
    localStorage.setItem(AUTH_TOKEN, token);
  }
  try {
    await client.resetStore();
  } catch (e) {
    // eslint-disable-next-line
    console.error(`Login Failed. ${e}`);
  }
}

// ログアウト処理
export async function onLogout() {
  if (typeof localStorage !== "undefined") {
    localStorage.removeItem(AUTH_TOKEN);
  }
  try {
    await client.resetStore();
  } catch (e) {
    // eslint-disable-next-line
    console.error(`Logout Failed. ${e}`);
  }
}

export const apolloProvider = new VueApollo({
  defaultClient: client
});

Vue routerでの遷移制御

beforeEachにて、各routeへの遷移前にのmeta情報の判定とcurrent_userの有無で遷移制御を行っています。

router/index.ts
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "@/views/Home.vue";
import Login from "@/views/Login.vue";
import { auth } from "@/plugins/firebase";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "home",
    component: Home,
    meta: { requireAuth: true }
  },
  {
    path: "/login",
    name: "login",
    component: Login
  }
];

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

router.beforeEach((to, _from, next) => {
  const requireAuth = to.matched.some(record => record.meta.requireAuth);
  const currentUser = auth.currentUser;

  if (!requireAuth || currentUser) {
    next();
    return;
  }

  next({
    path: "/login",
    query: { redirect: to.fullPath }
  });
});

export default router;

Firebase UIでのログインページ

ログインページではFirebase UIを利用してログインフォームを構築しています。

view/Login.vue
<template>
  <div class="about">
    <h2 class="text-center">Please login.</h2>
    <div id="firebaseui-auth-container"></div>
  </div>
</template>

<script>
import { auth } from "@/plugins/firebase";
import firebase from "firebase";
import * as firebaseui from "firebaseui";
import "firebaseui/dist/firebaseui.css";

export default {
  name: "login",
  beforeRouteEnter(to, from, next) {
    next(() => {
      const uiConfig = {
        signInSuccessUrl: "/",
        signInFlow: "popup",
        signInOptions: [
          firebase.auth.GoogleAuthProvider.PROVIDER_ID,
          firebase.auth.EmailAuthProvider.PROVIDER_ID
        ]
      };
      const ui =
        firebaseui.auth.AuthUI.getInstance() ||
        new firebaseui.auth.AuthUI(auth);
      ui.start("#firebaseui-auth-container", uiConfig);
    });
  }
};
</script>

memoの取得・削除

アプリのメインのmemo追加・削除部分の実装はこちらです。
vue-apolloにて通信を行っています。

view/Home.vue
<template>
  <div class="home">
    <v-card class="mb-5">
      <v-card-text>
        <v-textarea outlined label="Memo" single-line v-model="content" />
        <v-btn block @click="addMemo" color="primary">add Memo</v-btn>
      </v-card-text>
    </v-card>
    <template v-if="memos && memos.length > 0">
      <v-card v-for="memo in memos" :key="memo.id" class="mb-1">
        <v-card-text>
          {{ memo.content }}
        </v-card-text>
        <v-card-actions>
          <v-spacer/>
          <v-icon @click="deleteMemo(memo.id)">mdi-delete</v-icon>
        </v-card-actions>
      </v-card>
    </template>
  </div>
</template>

<script lang="ts">
import Vue from "vue";
import { FETCH_MEMOS } from "@/graphql/queries";
import { ADD_MEMO, DELETE_MEMO } from "@/graphql/mutations";
import { auth } from "@/plugins/firebase";

type Memo = {
  id: number;
  content: string;
  created_at: string;
};

type Data = {
  content: string;
  memos: Memo[];
};

export default Vue.extend({
  name: "home",
  data(): Data {
    return {
      content: "",
      memos: []
    };
  },
  methods: {
    async addMemo() {
      const res = await this.$apollo.mutate({
        mutation: ADD_MEMO,
        variables: {
          content: this.content,
          userId: (auth.currentUser as firebase.User).uid
        }
      });

      const insertResult = res.data.insert_memos.returning[0];
      this.memos.push({
        id: insertResult.id,
        content: insertResult.content,
        created_at: insertResult.created_at
      });

      this.clearField();
    },
    async deleteMemo(id: Number) {
      await this.$apollo.mutate({
        mutation: DELETE_MEMO,
        variables: {
          id
        }
      });

      this.memos = this.memos.filter(memo => memo.id !== id);
    },
    clearField() {
      this.content = "";
    }
  },
  apollo: {
    memos: {
      query: FETCH_MEMOS
    }
  }
});
</script>

最後に

以上、 「認証付きGraphQL APIサーバーを爆速で立てる。 Hasura + Firebase Authentication」でした。
Firebase AuthenticationをFirebaseのリソース以外の認証基盤として使うのは初めてだったので、JWTの認証方法などとても勉強になりました。Hasuraの日本語情報あまりなく、私もまだまだ勉強中です。もし誤表記や訂正などあればお気軽にコメントにて指摘願いします。

参考URL

ryo2132
Frontend engineer / フルリモートワーク / 元消防士🚒 / 一児の父 / Ruby / Typescript / Vue.js / Firebase
misoca
クラウド請求管理サービス「Misoca」を開発する、Misoca開発チームです。
https://www.misoca.jp/
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした