🐕

Firebase Authentication でホワイトリストを扱う(TypeScript)

2024/12/21に公開

Firebase Custom Claims の続き
ユーザー登録できるドメインまたはメールアドレスを指定してアカウント登録できる条件(ホワイトリスト)を管理したい

  • Admin権限を持っているユーザーは、ホワイトリストを更新できるようにする
  • メールアドレスについては一度登録したらホワイトリストから削除したい
    ※ 一時的にユーザーが作成されるのは防げないないので、トークンリフレッシュして削除されてないか確認する

要約

  • ホワイトリストを作る
    • Domains 一覧(登録して良いドメインリスト)
    • Emails 一覧(登録して良いEmailリスト)
  • アカウント削除機能の追加

想定している使い方
ドメイン全部許可できる場合はドメインを事前登録して使う
例外的にアカウントを登録したい場合は、メールアドレスを事前登録する

2種類のホワイトリスト

ホワイトリストをRealtime Databaseで管理する
Admin権限を持っていれば更新できるようにする(フロントエンドから更新しないのであればいらない)
事前にカスタムクレームのAdmin権限をセットしておくこと

リアルタイムデータベースの権限設定
{
  "rules": {
    ".read": "auth != null && auth.token.role == 'Admin'",
    ".write": "auth != null && auth.token.role == 'Admin'",
    "deleteUser":{
      ".read": "true"
    }
  }
}
realtimedbの中身
{
  "whitelist": {
    "domains": [
      "hoge.com",
      "hoge.jp"
    ],
    "emails":[
      "oreore@hogefoo.com"
    ]
  },
  "deleteUser": { //ホワイトリストに当たらず削除されたユーザー
    "-ODP7OHcZ_N7oEXEe1P3": {
        "email":"ng@hogefoo.com",
        "created":1733457712324
    }
  }
}

FrontEnd

Vue3でpiniaを使っていることを想定しています。
ユーザーの作成自体は前回と変更はありません。

リアルタイムデータベースを読み書きするリポジトリ
  • init: 初期化
  • onValue: 購読
  • setDomains: ドメインを更新
  • setEmails: メールを更新
    useRealtimeDbはfirebase/databaseのラッパー関数を自作しているので普通にライブラリから読み書きしたほうが良いかもです。
repository.ts
import { defineStore } from 'pinia';
import { useRealtimeDb } from './useRealtimedb';
import { getDatabase, onValue, ref } from 'firebase/database';
export const useRepository = defineStore('repository', {
  state: () => {
    return {
      whitelist: { domains: [], emails: [] } as whitelist,
    };
  },

  actions: {
    async init() {
      const snap = await useRealtimeDb().get('whitelist/');
      const whitelist = snap.val();
      this.whitelist.domains = whitelist?.domains || [];
      this.whitelist.emails = whitelist?.emails || []; 
    },
     /**
     * 購読するもの
     */
    onValue() {
      const db = getDatabase();
    
      // whitelist/domains の購読
      const domainsRef = ref(db, 'whitelist/domains');
      onValue(domainsRef, (snapshot) => {
        this.whitelist.domains = snapshot.val() || [];
      });
    
      // whitelist/emails の購読
      const emailsRef = ref(db, 'whitelist/emails');
      onValue(emailsRef, (snapshot) => {
        this.whitelist.emails = snapshot.val() || [];
      });
    },
    /**
     * ドメインリストを更新
     * @param domains ドメインリスト
     */
    async setDomains(domains: string[]) {
      try {
        await useRealtimeDb().set(domains, 'whitelist/domains');
      } catch (error) {
        console.error('Failed to set domains:', error);
      }
    },

    /**
     * メールリストを更新
     * @param emails メールリスト
     */
    async setEmails(emails: string[]) {
      try {
        await useRealtimeDb().set(emails, 'whitelist/emails');
      } catch (error) {
        console.error('Failed to set emails:', error);
      }
    },
  },
});

作成時に Cloud Functions の Create トリガーでカスタムクレームを付与していますが、反映されるまでにラグがあります。
認証方式によっては、フロントエンドで事前にホワイトリストをチェックできない場合があり、そのため一時的にユーザーが作成されることがあります。
また、カスタムトークンをリフレッシュしようとするとエラーが発生するため、role の取得を試み、取得できない場合は画面遷移や機能の利用を制限するようにしましょう。

auth.ts
async fetchUserRole() {
      try {
        if(!this.user){
          return;
        }
        await this.user.getIdToken(true); // トークンを更新
        const idTokenResult = await this.user.getIdTokenResult();
        this.role = idTokenResult.claims.role as 'Admin' | 'Member' | undefined;
      } catch (error) {
        this.handleError(error as AuthError);
      }

Backend

バックエンドは前回よりだいぶ複雑になりました。

  • ユーザー情報の取得 メールとドメインを抽出
  • ホワイトリストを取得
  • ドメインとメールアドレスの検証
    • メールアドレスは一度使ったら削除
  • デフォルトロールの設定
index.ts
export const setDefaultUserRole = functionsv1.auth.user().onCreate(async (user) => {
  const email = user.email;

  if (!email) {
    console.log('No email found for user:', user.uid);
    return null;
  }

  const domain = email.split('@')[1];
  try {
    // ホワイトリストのドメインとメールアドレスを取得
    const [allowedDomainsSnapshot, allowedEmailsSnapshot] = await Promise.all([
      admin.database().ref('whitelist/domains').once('value'),
      admin.database().ref('whitelist/emails').once('value'),
    ]);

    const allowedDomains = allowedDomainsSnapshot.val() || [];
    const allowedEmails = allowedEmailsSnapshot.val() || [];

    if (!allowedDomains.includes(domain)) {
      if (!allowedEmails.includes(email)) {
        await deleteCreateUser(user.uid, email);
        return null;
      }
      // ホワイトリストにあるメールアドレスを削除
      await removeEmailFromWhitelist(email, allowedEmails);
    }

    await assignUserRole(user.uid, email);
  } catch (error) {
    console.error('Error processing user creation:', error);
  }
  return;
});

アカウント削除機能の追加

FrontEnd

deleteUser ログインユーザーのfirebase uidを指定してユーザーを削除(Admin権限を持っていること)

useAuth.ts
async deleteUser(uid: string) {
      try {
        const user = getAuth().currentUser;
        if (!user) {
          throw new Error('User is not logged in.');
        }

        const idToken = await user.getIdToken();
        const response = await fetch('https://<-your api->.a.run.app', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${idToken}`,
          },
          body: JSON.stringify({ uid }),
        });

        if (response.ok) {
          // deleteUser APIのレスポンスに合わせて型を定義
          const data = (await response.json()) as { message: string };
          console.log('Success:', data.message);
          await this.fetchUsers();
        } else {
          const error = (await response.json()) as { error: string };
          console.error('Error:', error.error);
          alert(error.error);
        }
      } catch (error) {
        this.handleError(error as AuthError);
      }
    },

Backend

  1. Authorizationヘッダーの確認
  2. トークンの抽出と検証
  3. クエリパラメータの取得、検証
  4. Firebase Authenticationからユーザー情報の削除
  5. レスポンスの整形
index.ts
/**
 * ユーザーを削除する Cloud Function
 * HTTP POST リクエストで呼び出します。
 */
export const deleteUser = functions.https.onRequest(async (req, res) => {
try {
  const idToken = getIdToken(req);
  if (!idToken) {
    res.status(401).send({ error: 'Unauthorized: No token provided' });
    return;
  }

  const decodedToken = await admin.auth().verifyIdToken(idToken);
  const { role } = decodedToken;
  // デコードされたトークンから role を取得し、管理者であることを確認
  if (role !== 'Admin') {
    res.status(403).send({ error: 'Forbidden: Only Admin can delete user.' });
    return;
  }
  const { uid } = req.body;

  if (!uid) {
    res.status(400).send({ error: 'uid is required' });
    return;
  }

  await admin.auth().deleteUser(uid);
  res.status(200).send({ message: `Successfully deleted user:'${uid}'` });
} catch (error) {
  console.error('Error delete user:', error);
  res.status(500).send({ error: 'Failed delete user' });
}
});

感想

onAuthStateChanged の配置は重要で、呼び出しは1回だけにしたいのとrouterに直接絡めるのはよくなさそう。
カスタムクレームを設定することで、トークンをリフレッシュする際にログイン状態を確認できるようになりました。
フロントエンドからサインアップに使ったメールアドレスが取得できないこともあるため、サインアップできない理由が分からない状況が発生します。
これを解消するために、deleteUser を作るようにしました。

Discussion