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つのステップがあります。
- Hasuraのセットアップと、Hasuraコンソールを利用してデータモデルを作成します。
- 認証をセットアップします。
- 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 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_ROLE
にanonymous
を設定します。
なぜなら、認証されていないユーザに、データの書き込みと読み込みを許可するためです。
今から、データモデリングをしていきます。
最初に、name
, vote_count
フィールドを持つ、programming_language
テーブルが必要になります。
画面上部の「DATA」をクリックして、テーブル一覧画面に移動します。(補足)
「Create Table」をクリックして、テーブルを作成画面に移動します。(補足)
テーブル名、カラムを入力します。(補足)
Table Name
:programming_language
の「Columns」にname
:Text
vote_count
:Integer
Primary Key
:name
を入力します。- 「Create」をクリックして作成します。
同様に、ユーザーにある言語が好かれているかどうかを記録するためのloved_language
テーブルが必要です。
ユーザは、一度だけ言語を”好き”とマークすることができ、プライマリーキーとしてname
とuser_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実行画面に移動します。(補足)
SQLを張り付けて「RUN」をクリックします。(補足)
2つのテーブルを作成した後、Hasuraは、loved_language
とprogramming_language
の間の1対多の関係を認識し、対応するGraphQLのリレーションシップを作成することを手助けします。
programming_language
のArray relationships
に作成するリレーションの名前は、lovedLanguagesByname
にしてください。(補足)
あとで、Reactのソースコードに記述するGraphQLのクエリで指定するためです。(補足)
万歳!すでにデータモデルはできているので、GraphQLのAPIを試すことができます。
programming_language
にあなたの好きな言語をいくつか登録してください。
登録した言語に投票してください。
ランダムなuser_id
でlove_language
に登録してください。
Admin
としてサインインしているので、必要なことは何でもできます。
しかし、anonymous
とuser
ロールに適切な権限を設定する必要があります。
programming_language
のuser
ロールとanonymous
ロールにはselect
とupdate
の両方を許可します。
programming_language
をクリックした後、Permission
をクリックします。(補足)Role
にanonymous
を入力して、select
をクリックします。Row select permissions
のWithout any checks
を選択しますColumn select permissons
のname
とvote_count
を選択します- 「Save Permissions」をクリックします
select
の時と同様にupdate
も設定します。(補足)
Row select permissions
のWithout any checks
を選択しますColumn select permissons
のvote_count
にを選択します
Role
にuser
を追加して、同様にselect
とupdate
を設定します。
設定が終わると下記画像のようになるはずです。
loved_language
テーブルへのinsert
、select
、delete
は、user
ロールのみ許可します。
insert
するときのuser_id
は、X-Hasura-User-Id
から取得する必要があります
loved_language
をクリックします。(補足)
すでにanonymous
とuser
ロールはできているので、user
のinsert
をクリックします。(補足)
Insert: (補足)
Row insert Permissions
のWith custom check
を選択します。user_id
->eq
の順で選択し、X-Hasura-User-Id
を入力します。Column insert permissions
のname
にチェックを入れます。Column presets
のuser_id
とfrom session variable
を選択し、User-Id
を入力します。- 「Save Permissions」をクリックします。
Select:(補足)
user
のselect
をクリックします。Row select Permissions
のWith custom check
を選択します。user_id
->eq
の順で選択し、X-Hasura-User-Id
を入力します。Column select permissions
のname
とuser_id
を選択します。Aggregation queries permission
のAllow role user to make aggregation queries
を選択します。
Delete:(補足)
user
のdelete
をクリックします。Row delete Permissions
のWith custom check
を選択します。user_id
->eq
の順で選択し、X-Hasura-User-Id
を入力します。
設定が終わると、下記画像のようになるはずです。
権限を設定したので、X-Hasura-User-Idを安全に取得することが必要になります。
Step2 Firebase Auth
Firebase websiteに行って、新しいプロジェクトを作成してください。
デフォルトで無料プランなので、課金される心配はありません。
Firebase consoleの認証セクションで、Google サインイン機能をONにします。
このチュートリアルでは、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つ設定を変更する必要があります。
リアルタイムデータベースルールセクションに移動して、read
をfalse
からtrue
に変更します。
そうしなければ、クライアントはトークの更新を監視できません。
Step3 React
最後に、わくわくするUIを構築します。
GraphQL APIへの問い合わせには、Apollo Client
を利用します。
client setup guideに従って、必要なnpmパッケージをすべて追加してください。
アプリはシンプルなので、Auth
とApp
の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 )へ。