Firebaseで1アカウントのマルチユーザーに対して認証・認可を行う

Firebaseの標準の認証処理が要件に合わない。
というのも、1メールアカウントに対して複数のユーザーを設定できるようなシステムを構築しようとしているから。
イメージとしてはこんな感じ。

aaa@example.com
  |- wuser01 : password01
  |- wuser02 : password02
  |- wuser03 : password03
bbb@example.com
  |- xuser01 : password01
  |- xuser02 : password02
...

Firebaseの標準認証はemailやユーザーIDごとなので。
そこでFirebaseに用意されているカスタムトークンの仕組みを利用する。
Firebaseのカスタムトークン
これを使えばマルチテナントも同様に実装できる。

根本のやりたい事は、認証を行った後にFirestoreのセキュリティルールにより
データの参照や更新の認可を行いたいというもの。
Firestoreのドキュメントパスに対してアカウントによるセキュリティルールを適用するには
request.auth 変数に認証情報が入っている必要がある。

セキュリティルールの実装

まず、Firestoreのセキュリティルールの実装から。
これは、FirebaseのサイトからDatabaseを選択すると表示されるFirestoreの”ルール”タブで設定できる。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /test-collection-1/{doc1} {
      allow read, write: if resource.data.accountId == request.auth.token.accountId
    }
  }
}

今回はこのtest-collection-1コレクション配下のドキュメントのうち、accountIdが一致するデータのみ利用できるようにする。
Firebase標準のAuthenticationが利用できる場合は、request.auth.uidなどを利用するがそれが使えない。
そこで、token(カスタムトークン)の中にアカウントを識別するIDなどを含めてそれをifで照合し認可する。
上記のロジックでは、auth.token.accountIdの部分。

また、ifで指定した条件はデータのクエリ時にも一致させる必要がある。
上記の例では、firestoreにアクセスするクライアント側でデータを取得する際に、accountIdを必ず指定しなければならない。
でないと、permission errorとなる。

サンプル)クライアントからのFirestoreへのクエリ

const firestore = firebaseInstance.firestore();
firestore.collection('test-collection-1')
        .where("accountId", "==", "accountXXX")
...

Cloud Functionで認証処理(サーバ側)を実装する

では、ここから認証処理を実装する。
今回は認証処理にGCP Cloud Functionを利用し、実装にはNode.jsを選択。
ところでClient(Javascript)側からCloud Functionの呼び出しを利用する際にはCallable関数の形式を利用するのが楽。CORSの設定などが省けるから。トリガでHTTPを選択し、Node.jsのロジックの呼び出し口を後述するロジックのように書き換えましょう。
Callable関数
実装の際には、コード内でfirebase-admin、firebase-functionsライブラリを利用するためpackage.jsonに以下の記述をしておく。

{
 ...
  "dependencies": {
    "firebase-admin": "^8.8.0",
    "firebase-functions": "^3.3.0"
  }
}

そして、Cloud Functionの実装コード。

const functions = require("firebase-functions");
const admin = require("firebase-admin");
admin.initializeApp();  // CloudFunctionの実行サービスアカウントがFirebaseとリンクしていること

exports.testAuth = functions.https.onCall(async (data, context) => {

  let accountId = data.accountId;
  let userId = data.userId;
  let password = data.password;
  
  // 認証処理: ユーザーをFirestore上で管理しているため以下のようなロジック。
  // ここではuser-infoというコレクション配下にaccountIdごとのドキュメント、その配下にusersというサブコレクションを持たせている。
  // RDBなど別のDBで管理したいアナタは、ここをそれぞれの照合チェックに置き換えてください。
  var fireStore = admin.firestore();
  let accountDocRefs = await fireStore.collection('user-info')
  .where("accountId", "==", accountId).get();
  if (accountDocRefs.size > 0) {
    let accountDocRef = null;
    accountDocRefs.forEach((ref) => {
      accountDocRef = ref;
      return;
    });
    // usersサブコレクションの検索
    let userDocRef = await accountDocRef.ref.collection("users").doc(userId).get();
    if (userDocRef.exists) { 
      let userInfo = userDocRef.data();
      // userInfoドキュメントには項目としてpassword、userName、loginUserTypeが存在
      let checkPass = userInfo.password;
      if (checkPass == password) {
       	console.log("loginCheck: ok");
        // カスタムトークンの生成 ★ここがポイント
        // セキュリティルールで利用したい情報をここで詰め込む(token.xxxで参照可能になる)
        // ★RDBなどをサーバとして認証するアナタは上記ロジックは無視してここだけ見るよろし。
        let additionalClaims = {
          loginUserType: userInfo.loginUserType,
          accountId: accountId
        };
        let tokenData = await admin.auth().createCustomToken(userId, additionalClaims);
        // tokenと一緒にクライアントに返したい情報をCallable関数の戻り値に詰め込む
      	return {
          token: tokenData,
          accountDocId: accountDocRef.id,
          accountId: accountId,
          userId: userId,
          userName: userInfo.userName,
          loginUserType: userInfo.loginUserType
        };
      }
      else {
        console.log("loginCheck: password no match");
        return null;
      }
    } else {
      console.log("loginCheck: userId no match");
      return null;
    }
  } else {
    console.log("loginCheck: email no match");
    return null;
  }
});

このCloud Function(Callable関数)の各種設定
・Cloud Functionの登録名をtest-auth
・トリガーは: HTTP
・実行する関数には: testAuth (exportsしている関数名)
・(実行する)サービスアカウントは: Firebase側に合わせる
Firebaseコンソールから「プロジェクトの設定」 > サービスアカウント > Firebase Admin SDK (作成)
また、このサービスアカウントにはGCPのIAMから「サービス アカウント トークン作成者」を追加しておく。
詳しくは以下ページのトラブルシューティングの項を参照
Firebaseのカスタムトークン

認証を開始するクライアント側WEBロジック

そして、Callable関数を呼び出すクライアント側ロジックは次のようになる。

      let data = {
        accountId: "test1@gmail.com",
        userId: "user01",
        password: "xxxxxx"
      };
      const func = firebaseInstance.functions().httpsCallable('test-auth');
      func(data)
      .then(function(result) {
        if (!result) {
          throw new Error("アカウントID、ユーザーIDまたはパスワードが間違っています");
        }
        // ログイン処理: この部分は同期処理であることに注意
        firebaseInstance.auth().signInWithCustomToken(result.data.token).catch(function(error) {
          var errorCode = error.code;
          var errorMessage = error.message;
          console.log(errorCode);
          console.log(errorMessage);
          throw new Error("認証システムに障害が発生しています");
        });
        // ログイン処理が終わるとこの行以降が実行される
        console.log("Login success");
        // クライアント側で利用したい情報を取得: Cloud Functionで自分が設定した情報
        console.log(result.data.userName);
        ...
        // ログインがうまくいった時の処理
        ...
      }.bind(this))
      .catch(error => {
        console.log(error);
      });

クライアントのロジック側で、auth().signInWithCustomTokenが通った状態でFirestoreにクエリを投げると冒頭で示したrequest.auth.token.accountIdの部分に認証されたIDが入り、Firestoreのセキュリティルールに従ってデータが制限、取得される事が確認できる。

以上

コメントを残す

メールアドレスが公開されることはありません。