使用自定义声明和安全规则控制访问权限

Firebase Admin SDK 支持为用户帐号定义自定义属性。您可以借此功能在 Firebase 应用中实施各种访问控制策略,包括基于角色的访问控制。这些自定义属性可以授予用户不同的访问权限级别(角色),通过应用的安全规则强制施行。

您可以针对以下常见情况定义用户角色:

  • 向用户授予管理权限,以允许其访问数据和资源。
  • 定义用户所属的不同群组。
  • 提供多级别访问权限:
    • 区分付费/免费订阅者。
    • 区分管理员与常规用户。
    • 教师/学生应用等。
  • 为用户添加额外的标识符。例如,Firebase 用户可以映射到另一个系统中的不同 UID。

假设您要限制对数据库节点“adminContent”的访问。您可以对管理员用户列表进行数据库查询来实现此目的。但是,有一种方法可以更高效地达成此目标,那就是搭配使用名为 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 预留的名称。自定义声明有效负载不得超过 1000 字节。

您可以使用 Admin SDK,根据发送到后端服务器的 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 令牌检索。要根据用户的角色或访问权限级别修改客户端界面,可能就需要访问这些声明。但是,必须确保在验证 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 令牌)。

  • 仅使用自定义声明来存储用于控制用户访问权限的数据。所有其他数据均应通过实时数据库或其他服务器端存储系统单独存储。
  • 自定义声明的大小是有限的。自定义声明有效负载不得超过 1000 字节,否则在传递时系统会抛出错误。

示例和使用情形

下面的示例将结合特定的 Firebase 使用情形来说明自定义声明。

在创建用户时通过 Firebase 函数定义角色

在此例中,我们在创建用户时使用 Cloud Functions 为其设置自定义声明。

您可以使用 Cloud Functions 添加自定义声明,然后借助实时数据库即时传播。该函数只会在注册时使用 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 逻辑

下例中添加了一个新的数据库节点(元数据/($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);
});

发送以下问题的反馈:

此网页
需要帮助?请访问我们的支持页面