カスタム クレームとセキュリティ ルールによるアクセスの制御

Firebase Admin SDK では、ユーザー アカウントのカスタム属性の定義がサポートされています。これにより、Firebase アプリにさまざまなアクセス制御戦略(役割ベースのアクセス制御など)を実装できます。カスタム属性を使用することで、ユーザーにさまざまなアクセスレベル(役割)を付与できます。このようなアクセスレベルは、アプリケーションのセキュリティ ルールに適用されます。

ユーザー役割は、以下の一般的な状況で定義できます。

  • データとリソースにアクセスするための管理者権限をユーザーに付与する。
  • ユーザーが属するさまざまなグループを定義する。
  • マルチレベル アクセス権限を指定する。
    • 有料 / 無料のサブスクライバーの区別。
    • 通常のユーザーとモデレータの区別。
    • 教師 / 学生のアプリケーションなど。
  • ユーザーに ID を追加する。たとえば Firebase ユーザーは、別のシステムの異なる UID にマップできます。

データベース ノード "adminContent" へのアクセスを制限する状況について考えてみましょう。これは、管理者ユーザーのリストに対するデータベース検索により行えます。ただし、次の Realtime Database ルールで admin というカスタム ユーザー クレームを使用すると、同じ目的をより効率的に達成できます。

{
  "rules": {
    "adminContent": {
      ".read": "auth.token.admin === true",
      ".write": "auth.token.admin === true",
    }
  }
}

カスタム ユーザー クレームは、ユーザーの認証トークンを使用してアクセスできます。上記の例では、トークン クレームで admin が true に設定されているユーザーにのみ、adminContent ノードへの読み取り / 書き込みアクセス権限があります。ID トークンにはすでにこのようなアサーションが含まれているため、管理者権限を確認するための追加の処理や検索は不要です。また、ID トークンはカスタム クレームを配信するための信頼できる配信メカニズムです。すべての認証済みアクセスでは、関連付けられているリクエストを処理する前に、ID トークンを検証する必要があります。

Admin SDK でカスタム ユーザー クレームを設定および検証する

カスタム クレームには機密データが含まれている可能性があるため、Firebase Admin SDK によって特権サーバー環境からのみ設定される必要があります。次に示すように、Node.js を使用してクレームを設定できます。

// Set admin privilege on the user corresponding to uid.
admin.auth().setCustomUserClaims(uid, {admin: true}).then(() => {
  // The new custom claims will propagate to the user's ID token the
  // next time a new one is issued.
});

カスタム クレーム オブジェクトには OIDC 予約キー名や Firebase 予約名を含めないでください。カスタム クレームのペイロードは 1,000 バイト以下でなければなりません。

次のように Admin SDK を使用して、バックエンド サーバーに送信される ID トークンにより、ユーザーの ID とアクセスレベルを確認できます。

 // Verify the ID token first.
 admin.auth().verifyIdToken(idToken).then((claims) => {
   if (claims.admin === true) {
     // Allow access to requested admin resource.
   }
 });

ユーザーの既存のカスタム クレームも確認できます。これは UserRecord オブジェクトのプロパティとして取得できます。

 // Lookup the user associated with the specified uid.
 admin.auth().getUser(uid).then((userRecord) => {
   // The claims can be accessed on the user record.
   console.log(userRecord.customClaims.admin);
 });

ユーザーのカスタム クレームを削除するには、customClaims に null を渡します。

クライアントへカスタム クレームを伝播する

Admin SDK でユーザーの新しいクレームが変更されると、次のように ID トークンによってクライアント側の認証済みユーザーに伝播されます。

  • カスタム クレームの変更後に、ユーザーがログインまたは再認証する。その結果として発行された ID トークンには最新のクレームが含まれる。
  • 古いトークンが期限切れになると、既存のユーザー セッションでその ID トークンが更新される。
  • currentUser.getIdToken(true) を呼び出して ID トークンが強制的に更新される。

クライアントのカスタム クレームにアクセスする

カスタム クレームは、ユーザーの ID トークンでのみ取得できます。ユーザーの役割やアクセスレベルに基づいてクライアント UI を変更するには、カスタム クレームへのアクセスが必要となります。一方、バックエンド アクセスは常に ID トークンの検証とそのクレームの解析の完了後に、ID トークンによって適用される必要があります。カスタム クレームはトークンの外部では信頼できないため、バックエンドに直接送信しないでください。

最新のクレームがユーザーの ID トークンに伝播されたら、まず ID トークンを取得し、次にそのペイロード(base64 デコード済み)を解析することにより、クレームを取得できます。

// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
firebase.auth().currentUser.getIdToken()
  .then((idToken) => {
     // Parse the ID token.
     const payload = JSON.parse(b64DecodeUnicode(idToken.split('.')[1]));
     // Confirm the user is an Admin.
     if (!!payload['admin']) {
       showAdminUI();
     }
  })
  .catch((error) => {
    console.log(error);

カスタム クレームのベスト プラクティス

カスタム クレームは、アクセス制御を提供するためだけに使用されます。追加のデータ(プロファイルやその他のカスタムデータなど)を格納するようには設計されていません。追加のデータを格納するための便利なメカニズムに見えますが、このような目的で使用することは推奨されません。これは、カスタム クレームが ID トークンに含まれており、またすべての認証済みリクエストにはログイン ユーザーに対応する Firebase ID トークンが常に含まれていることが原因で、パフォーマンスの問題を引き起こす可能性があるからです。

  • カスタム クレームは、ユーザー アクセス制御のためのデータを格納する目的でのみ使用します。その他のデータはすべて、Realtime Database またはその他のサーバー側ストレージに個別に格納する必要があります。
  • カスタム クレームのサイズは制限されています。1,000 バイトを超えるカスタム クレームのペイロードを渡すと、エラーがスローされます。

例とユースケース

特定の Firebase ユースケースにおけるカスタム クレームを以下の例に示します。

ユーザー作成時の Firebase Functions を使用した役割の定義

この例では、Cloud Functions を使用してユーザー作成時にカスタム クレームをそのユーザーに設定します。

Cloud Functions を使用してカスタム クレームを追加できます。また、Realtime Database を使用してカスタム クレームを即時に伝播できます。この関数は、onCreate トリガーを使用してログインする場合にのみ呼び出されます。設定されたカスタム クレームは、すべての既存のセッションおよび今後のセッションに伝播されます。次回ユーザーがユーザー認証情報を使用してログインすると、トークンにはカスタム クレームが含まれています。

クライアント側の実装(JavaScript)

const provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider)
.catch(error => {
  console.log(error);
});

let callback = null;
let metadataRef = null;
firebase.auth().onAuthStateChanged(user => {
  // Remove previous listener.
  if (callback) {
    metadataRef.off('value', callback);
  }
  // On user login add new listener.
  if (user) {
    // Check if refresh is required.
    metadataRef = firebase.database().ref('metadata/' + user.uid + '/refreshTime');
    callback = (snapshot) => {
      // Force refresh to pick up the latest custom claims changes.
      // Note this is always triggered on first call. Further optimization could be
      // added to avoid the initial trigger when the token is issued and already contains
      // the latest claims.
      user.getIdToken(true);
    };
    // Subscribe new listener to changes on that node.
    metadataRef.on('value', callback);
  }
});

Cloud Functions のロジック

読み取り / 書き込みが認証済みユーザーに制限された新しいデータベース ノード(metadata/($uid))が追加されます。

const functions = require('firebase-functions');

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

// On sign up.
exports.processSignUp = functions.auth.user().onCreate(event => {
  const user = event.data; // The Firebase user.
  // Check if user meets role criteria.
  if (user.email &&
      user.email.indexOf('@admin.example.com') != -1 &&
      user.emailVerified) {
    const customClaims = {
      admin: true,
      accessLevel: 9
    };
    // Set custom user claims on this newly created user.
    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);
      });
  }
});

データベース ルール

{
  "rules": {
    "metadata": {
      "$user_id": {
        // Read access only granted to the authenticated user.
        ".read": "$user_id === auth.uid",
        // Write access only via Admin SDK.
        ".write": false
      }
    }
  }
}

HTTP リクエストを使用した役割の定義

以下の例では、HTTP リクエストを使用して、新しくログインしたユーザーにカスタム ユーザー クレームを設定します。

クライアント側の実装(JavaScript)

const provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider)
.then((result) => {
  // User is signed in. Get the ID token.
  return result.user.getIdToken();
})
.then((idToken) => {
  // Pass the ID token to the server.
  $.post(
    '/setCustomClaims',
    {
      idToken: idToken
    },
    (data, status) => {
      // This is not required. You could just wait until the token is expired
      // and it proactively refreshes.
      if (status == 'success' && data) {
        const json = JSON.parse(data);
        if (json && json.status == 'success') {
          // Force token refresh. The token claims will contain the additional claims.
          firebase.auth().currentUser.getIdToken(true);
        }
      }
    });
}).catch((error) => {
  console.log(error);
});

バックエンドの実装(Admin SDK)

app.post('/setCustomClaims', (req, res) => {
  // Get the ID token passed.
  const idToken = req.body.idToken;
  // Verify the ID token and decode its payload.
  admin.auth().verifyIdToken(idToken).then((claims) => {
    // Verify user is eligible for additional privileges.
    if (typeof claims.email !== 'undefined' &&
        typeof claims.email_verified !== 'undefined' &&
        claims.email_verified &&
        claims.email.indexOf('@admin.example.com') != -1) {
      // Add custom claims for additional privileges.
      admin.auth().setCustomUserClaims(claims.sub, {
        admin: true
      }).then(function() {
        // Tell client to refresh token on user.
        res.end(JSON.stringify({
          status: 'success'
        });
      });
    } else {
      // Return nothing.
      res.end(JSON.stringify({status: 'ineligible'});
    }
  });
});

既存のユーザーのアクセスレベルをアップグレードするときには、同じフローを使用できます。無料ユーザーから有料サブスクリプションにアップグレードする例で説明します。ユーザーの ID トークンは、支払い情報とともに HTTP リクエストによりバックエンド サーバーに送信されます。支払いが正常に処理されると、Admin SDK によりユーザーは有料サブスクライバーとして設定されます。成功を示す HTTP レスポンスがクライアントに返され、トークンが強制的に更新されます。

バックエンド スクリプトによる役割の定義

ユーザーのカスタム クレームを更新するために繰り返しスクリプト(クライアントから開始されないスクリプト)を実行するように設定できます。

admin.auth().getUserByEmail('user@admin.example.com').then((user) => {
  // Confirm user is verified.
  if (user.emailVerified) {
    // Add custom claims for additional privileges.
    // This will be picked up by the user on token refresh or next sign in on new device.
    return admin.auth().setCustomUserClaims(user.uid, {
      admin: true
    });
  }
}).catch((error) => {
  console.log(error);
});

カスタム クレームは、Admin SDK により段階的に変更することもできます。

admin.auth().getUserByEmail('user@admin.example.com').then((user) => {
  // Add incremental custom claim without overwriting existing claims.
  const currentCustomClaims = user.customClaims;
  if (currentCustomClaims.admin) {
    // Add level.
    currentCustomClaims['accessLevel'] = 10;
    // Add custom claims for additional privileges.
    return admin.auth().setCustomUserClaims(user.uid, currentCustomClaims);
  }
}).catch((error) => {
  console.log(error);
});

フィードバックを送信...

ご不明な点がありましたら、Google のサポートページをご覧ください。