Firebaseを利用して、リアルタイムHasuraアプリに認証と承認を追加するチュートリアル

この記事は、A tutorial for using Firebase to add authentication and authorization to a realtime Hasura app の翻訳記事です

理解を助けるような画像や説明を、追加しています。

イントロダクション

Hasuraは、製品レベルの 3factorアプリを非常に素早く構築できます。

PostgresSQLデータベースから CRUD GraphQL APIsを生成することに加えて、webhookとJWTを利用したユーザ認証の方法を提供し、GraphQL schema(承認)に対して細かいアクセスコントロールルールの定義を手助けします。

しかしながら、認証システムを、Hasuraバックエンドといくつかのフロントエンドと統合するには、まだ必要な試みがあり、ややこしいかもしれません。

このチュートリアルでは、sample appのようなリアルタイム投票アプリを作ることによって、実際に動くことを目指します。

また、認証を組み込みます。

私たちは、バックエンドにHasura、認証にFirebase Authentication、フロントエンドにReactを利用します。

主に3つのステップがあります。

  1. Hasuraのセットアップと、Hasuraコンソールを利用してデータモデルを作成します。
  2. 認証をセットアップします。
  3. React Webアプリを構築します。

必要要件

  • React
  • GraphQL
  • Some SQL

デモを試す

動作するデモはhasura-vote-.now.sh にあり、それを試すことができます。

それは、あなたのお気に入りのプログラミング言語に投票することができる、シンプルなアプリです。

投票はリアルタイムに更新されます。

サインインした後、言語に”好き”とマークすることができます。

Firebase Authentication VS 自分で認証システムを構築する

堅牢な認証システムを構築することは、簡単ではありません。

それを頑張るよりも、アプリの開発サイクルを回すことがとても重要です。

このチュートリアルの目標は、HasuraとReactを使って認証システムを統合することであり、既製品のFirebase Authenticationを利用します。

Firebaseは、セキュアで、サードパーティやパスワードレスのサインインといった多くの機能があり、無料の利用枠があります。

Step1 Hasura

Hasuraを動かすのはとっても簡単です。

Deploy Hasura to Heroku Guideに従って、最後までやると、https://[your-heroku-project-name].herokuapp.comのようなドメインで新しいインスタンスが動きます。

heroku.png

Heroku consoleにいくつかの環境変数を設定する必要があります。

忘れないうちに、HASURA_GRAPHQL_ADMIN_SECRETへ秘密の文字を設定し、メモを残しておきましょう、そうすることで、インターネット上の誰かがAPIにアクセスすることを防げます。

FirebaseのJWTを使うので、HASURA_GRAPHQL_JWT_SECRET{"type":"RS512", "jwk_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"}を設定します。

最後に、HASURA_GRAPHQL_UNAUTHORIZED_ROLEanonymousを設定します。

なぜなら、認証されていないユーザに、データの書き込みと読み込みを許可するためです。

今から、データモデリングをしていきます。

最初に、name, vote_countフィールドを持つ、programming_languageテーブルが必要になります。

画面上部の「DATA」をクリックして、テーブル一覧画面に移動します。(補足) スライド1.JPG

「Create Table」をクリックして、テーブルを作成画面に移動します。(補足) スライド2.JPG

テーブル名、カラムを入力します。(補足)

  • Table Name: programming_languageの「Columns」に
    • name : Text
    • vote_count : Integer
    • Primary Key: nameを入力します。
  • 「Create」をクリックして作成します。 スライド3.JPG

同様に、ユーザーにある言語が好かれているかどうかを記録するためのloved_languageテーブルが必要です。

ユーザは、一度だけ言語を”好き”とマークすることができ、プライマリーキーとしてnameuser_idを組み合わせて設定します。

「Add Table」ボタンには、Foreign Keysを設定する方法はありませんが、Hasuraには、生のSQLを実行するための、便利な方法を提供します。(補足)

※現在のバージョンでは、「Add Table」ボタンからForeign Keyを設定できます。

SQL:

CREATE TABLE "public"."loved_language" (
     "name" text NOT NULL,
     "user_id" text NOT NULL,
     CONSTRAINT loved_language_pkey PRIMARY KEY (name, user_id),
     CONSTRAINT loved_language_programming_language_fky FOREIGN KEY (name) REFERENCES programming_language(name)
 )

「SQL」をクリックして、SQL実行画面に移動します。(補足) スライド4.JPG

SQLを張り付けて「RUN」をクリックします。(補足) スライド5.JPG

2つのテーブルを作成した後、Hasuraは、loved_languageprogramming_languageの間の1対多の関係を認識し、対応するGraphQLのリレーションシップを作成することを手助けします。

スライド6.JPG

programming_languageArray relationshipsに作成するリレーションの名前は、lovedLanguagesBynameにしてください。(補足)

あとで、Reactのソースコードに記述するGraphQLのクエリで指定するためです。(補足)

万歳!すでにデータモデルはできているので、GraphQLのAPIを試すことができます。

programming_languageにあなたの好きな言語をいくつか登録してください。

登録した言語に投票してください。

ランダムなuser_idlove_languageに登録してください。

Adminとしてサインインしているので、必要なことは何でもできます。

しかし、anonymoususerロールに適切な権限を設定する必要があります。

programming_languageuserロールとanonymousロールにはselectupdateの両方を許可します。

スライド7.JPG

  • programming_languageをクリックした後、Permissionをクリックします。(補足)
  • Roleanonymousを入力して、selectをクリックします。
  • Row select permissionsWithout any checksを選択します
  • Column select permissonsnamevote_countを選択します
  • 「Save Permissions」をクリックします

スライド8.JPG

selectの時と同様にupdateも設定します。(補足)

  • Row select permissionsWithout any checksを選択します
  • Column select permissonsvote_countにを選択します

Roleuserを追加して、同様にselectupdateを設定します。

設定が終わると下記画像のようになるはずです。 スライド9.JPG

loved_languageテーブルへのinsertselectdeleteは、userロールのみ許可します。

insertするときのuser_idは、X-Hasura-User-Idから取得する必要があります

スライド10.JPG

loved_languageをクリックします。(補足)

すでにanonymoususerロールはできているので、userinsertをクリックします。(補足)

Insert: (補足)

  • Row insert PermissionsWith custom checkを選択します。
    • user_id -> eq の順で選択し、X-Hasura-User-Idを入力します。
  • Column insert permissionsnameにチェックを入れます。
  • Column presetsuser_idfrom session variableを選択し、User-Idを入力します。
  • 「Save Permissions」をクリックします。

Select:(補足)

  • userselectをクリックします。
  • Row select PermissionsWith custom checkを選択します。
    • user_id -> eq の順で選択し、X-Hasura-User-Idを入力します。
  • Column select permissionsnameuser_idを選択します。
  • Aggregation queries permissionAllow role user to make aggregation queriesを選択します。

Delete:(補足)

  • userdeleteをクリックします。
  • Row delete PermissionsWith custom checkを選択します。
    • user_id -> eq の順で選択し、X-Hasura-User-Idを入力します。

設定が終わると、下記画像のようになるはずです。 スライド11.JPG

権限を設定したので、X-Hasura-User-Idを安全に取得することが必要になります。

Step2 Firebase Auth

Firebase websiteに行って、新しいプロジェクトを作成してください。

デフォルトで無料プランなので、課金される心配はありません。

Firebase consoleの認証セクションで、Google サインイン機能をONにします。

Firebase.png

このチュートリアルでは、Google sign-inを利用しますが、他のプロバイダを追加するのは簡単です。

ページ下部のAuthorized domainsに注目してください、localhostとFirebaseのドメインが自動で追加されています。

今後、他のドメインにReact appをデプロイする場合は、Google sign-inを利用するために、ここの「Authorized domains」に追加する必要があります。

そして、Firebase JS SDKを利用して、Reactアプリのユーザとしてサインインしたあと、Hasuraからidトークンを取得することができます。

しかし、HasuraがFirebaseに保存されている、これらのユーザが同一であることを知るために、Hasuraのトークンに特定のcustom claimsを追加する必要があります。

これをやるためには、Hasura repoのexampleに従って、FirebaseのCloud Functionsを利用します。

Cloud Functionは、「Firebaseの機能とHTTPSリクエストによってトリガーされたイベントに応答して」自動的に実行される機能です。

今回のイベントは、firebaseのユーザ作成です。

それが起きたとき、ユーザのid-tokenにデータを追加します。

このコードは明快です。

const functions = require("firebase-functions");
 const admin = require("firebase-admin");
 admin.initializeApp(functions.config().firebase);

 // On sign up.
 exports.processSignUp = functions.auth.user().onCreate(user => {
   const customClaims = {
     "https://hasura.io/jwt/claims": {
       "x-hasura-default-role": "user",
       "x-hasura-allowed-roles": ["user"],
       "x-hasura-user-id": user.uid
     }
   };

   return admin
     .auth()
     .setCustomUserClaims(user.uid, customClaims)
     .then(() => {
       // Update real-time database to notify client to force refresh.
       const metadataRef = admin.database().ref("metadata/" + user.uid);
       // Set the refresh time to the current UTC timestamp.
       // This will be captured on the client to force a token refresh.
       return metadataRef.set({ refreshTime: new Date().getTime() });
     })
     .catch(error => {
       console.log(error);
     });
 });

コメントのついているコードには、もう少し説明が必要です。

React appにユーザがサインインした時、Firebaseは、Hasuraに送信する必要のあるid-tokenを含めて。ユーザのデータをすぐに提供します。

しかし、もし初回ログインの時は、ユーザはすぐ作成されて、これらのcustom claimsは含まれない可能性があります。

これが、トークンの更新を監視する、Firebase real-time databaseを利用する理由です。

Cloud Functionをデプロイするのは簡単です。

公式のGet started guideに従って、index.jsを上記のコードに置き換えて、firebase deploy --only functionsを実行してください。

それでおしまい。

Firebase consoleを閉じる前に、もう1つ設定を変更する必要があります。

リアルタイムデータベースルールセクションに移動して、readfalseからtrueに変更します。

そうしなければ、クライアントはトークの更新を監視できません。

Step3 React

最後に、わくわくするUIを構築します。

GraphQL APIへの問い合わせには、Apollo Clientを利用します。

client setup guideに従って、必要なnpmパッケージをすべて追加してください。

アプリはシンプルなので、AuthAppの2つのコンポーネントのみ構築します。

AuthはユーザのサインインにFirebase SDKを利用して、Appに状態を渡します。

Appにはすべてのビジネスロジックが含まれており、リアルタイムデータの購読、投票そして、"好き"と言語にマークします。

Auth:

import firebase from "firebase/app";
import "firebase/auth";
import "firebase/database";
import React, { useState, useEffect } from "react";
import App from "./App";

const provider = new firebase.auth.GoogleAuthProvider();

// Find these options in your Firebase console
firebase.initializeApp({
  apiKey: "xxx",
  authDomain: "xxx",
  databaseURL: "xxx",
  projectId: "xxx",
  storageBucket: "xxx",
  messagingSenderId: "xxx"
});

export default function Auth() {
  const [authState, setAuthState] = useState({ status: "loading" });

  useEffect(() => {
    return firebase.auth().onAuthStateChanged(async user => {
      if (user) {
        const token = await user.getIdToken();
        const idTokenResult = await user.getIdTokenResult();
        const hasuraClaim =
          idTokenResult.claims["https://hasura.io/jwt/claims"];

        if (hasuraClaim) {
          setAuthState({ status: "in", user, token });
        } else {
          // Check if refresh is required.
          const metadataRef = firebase
            .database()
            .ref("metadata/" + user.uid + "/refreshTime");

          metadataRef.on("value", async () => {
            // Force refresh to pick up the latest custom claims changes.
            const token = await user.getIdToken(true);
            setAuthState({ status: "in", user, token });
          });
        }
      } else {
        setAuthState({ status: "out" });
      }
    });
  }, []);

  const signInWithGoogle = async () => {
    try {
      await firebase.auth().signInWithPopup(provider);
    } catch (error) {
      console.log(error);
    }
  };

  const signOut = async () => {
    try {
      setAuthState({ status: "loading" });
      await firebase.auth().signOut();
      setAuthState({ status: "out" });
    } catch (error) {
      console.log(error);
    }
  };

  let content;
  if (authState.status === "loading") {
    content = null;
  } else {
    content = (
      <>
        <div>
          {authState.status === "in" ? (
            <div>
              <h2>Welcome, {authState.user.displayName}</h2>
              <button onClick={signOut}>Sign out</button>
            </div>
          ) : (
            <button onClick={signInWithGoogle}>Sign in with Google</button>
          )}
        </div>

        <App authState={authState} />
      </>
    );
  }

  return <div className="auth">{content}</div>;
}

あなたが、新しい Hooks APIに慣れているなら、このコードは明快です。

Firebase Cloud Functionで設定した更新時間を監視するFirebaseリアルタイムデータベースをどのように利用するか注目してください。

無駄な更新を防ぐために、custom claimsにユーザid-tokenが含まれているか確認します。

App:

import { InMemoryCache } from "apollo-cache-inmemory";
import ApolloClient from "apollo-client";
import { split } from "apollo-link";
import { HttpLink } from "apollo-link-http";
import { WebSocketLink } from "apollo-link-ws";
import { getMainDefinition } from "apollo-utilities";
import gql from "graphql-tag";
import React from "react";
import { ApolloProvider, Mutation, Subscription } from "react-apollo";

const PL_SUB = gql`
  subscription PL {
    programming_language(order_by: { vote_count: desc }) {
      name
      vote_count
    }
  }
`;

const PL_WITH_LOVE_SUB = gql`
  subscription PL_WITH_LOVE($userId: String!) {
    programming_language(order_by: { vote_count: desc }) {
      name
      vote_count
      lovedLanguagesByname_aggregate(where: { user_id: { _eq: $userId } }) {
        aggregate {
          count
        }
      }
    }
  }
`;

const VOTE_MUTATION = gql`
  mutation Vote($name: String!) {
    update_programming_language(
      _inc: { vote_count: 1 }
      where: { name: { _eq: $name } }
    ) {
      returning {
        vote_count
      }
    }
  }
`;

const LOVE_MUTATION = gql`
  mutation Love($name: String!) {
    insert_loved_language(objects: { name: $name }) {
      affected_rows
    }
  }
`;

const UNLOVE_MUTATION = gql`
  mutation Unlove($name: String!) {
    delete_loved_language(where: { name: { _eq: $name } }) {
      affected_rows
    }
  }
`;

export default function App({ authState }) {
  const isIn = authState.status === "in";

  const headers = isIn ? { Authorization: `Bearer ${authState.token}` } : {};

  const httpLink = new HttpLink({
    uri: "https://your-heroku-domain/v1alpha1/graphql",
    headers
  });

  const wsLink = new WebSocketLink({
    uri: "wss://your-heroku-domain/v1alpha1/graphql",
    options: {
      reconnect: true,
      connectionParams: {
        headers
      }
    }
  });

  const link = split(
    ({ query }) => {
      const { kind, operation } = getMainDefinition(query);
      return kind === "OperationDefinition" && operation === "subscription";
    },
    wsLink,
    httpLink
  );

  const client = new ApolloClient({
    link,
    cache: new InMemoryCache()
  });

  return (
    <ApolloProvider client={client}>
      <Subscription
        subscription={isIn ? PL_WITH_LOVE_SUB : PL_SUB}
        variables={
          isIn
            ? {
                userId: authState.user.uid
              }
            : null
        }
      >
        {({ data, loading, error }) => {
          if (loading) return "loading...";
          if (error) return error.message;

          return (
            <ul className="pl-list">
              {data.programming_language.map(pl => {
                const { name, vote_count } = pl;

                let content = null;
                if (isIn) {
                  const isLoved =
                    pl.lovedLanguagesByname_aggregate.aggregate.count === 1;
                  if (isLoved) {
                    content = (
                      <Mutation mutation={UNLOVE_MUTATION} variables={{ name }}>
                        {unlove => <button onClick={unlove}>Unlove</button>}
                      </Mutation>
                    );
                  } else {
                    content = (
                      <Mutation mutation={LOVE_MUTATION} variables={{ name }}>
                        {love => <button onClick={love}>Love</button>}
                      </Mutation>
                    );
                  }
                }

                return (
                  <li key={name}>
                    <span>{`${name} - ${vote_count}`}</span>
                    <span>
                      <Mutation mutation={VOTE_MUTATION} variables={{ name }}>
                        {vote => <button onClick={vote}>Vote</button>}
                      </Mutation>
                      {content}
                    </span>
                  </li>
                );
              })}
            </ul>
          );
        }}
      </Subscription>
    </ApolloProvider>
  );
}

HasuraとGraphQLを利用して、色々な認証状態に基づいて、データを柔軟に問い合わせできるようになったことに注目してください。

まとめ

このチュートリアルでは、Hasuraを使ってリアルタイム投票アプリを構築しました。

バックエンドとフロントエンドを堅牢な認証システムで統合します。

Hasuraが、退屈で難しい仕事を、GraphQLと認証インタフェースの連携によって、いかに簡単に作れるかを見ることができます。

このモデルに基づいて、大体の素晴らしいアプリを制限なく構築することができます。

このチュートリアルのすべてのコードは、Github repoにあります。

元記事の著者について

Junyu Zhanは、中国に住むフルスタックWeb開発者です。

Flutter、React、そしてHasuraを使った、クロスプラットフォームの構築が得意です。

彼は、趣味で、たくさん歩くことや水泳をすることが大好きです。

彼と連絡を取りたいときは、email address(thezjy@gmail.com) か Twitter( https://twitter.com/thezjy1 )へ。