使用 Cloud Functions 屏蔽函数扩展 Firebase Authentication


通过屏蔽函数,您可以执行自定义代码,以修改用户注册或登录应用的结果。例如,您可以禁止未满足特定条件的用户通过身份验证,或者首先对用户的信息进行更新,然后再将此信息返回到您的客户端应用。

准备工作

如需使用屏蔽函数,您必须将 Firebase 项目升级到 Firebase Authentication with Identity Platform。如果您尚未升级,请先升级。

了解屏蔽函数

您可以为两种事件注册屏蔽函数:

  • beforeCreate:在将新用户保存到 Firebase Authentication 数据库之前以及将令牌返回给客户端应用之前触发。

  • beforeSignIn:在用户的凭据通过验证之后、Firebase Authentication 将 ID 令牌返回给您的客户端应用之前触发。如果您的应用使用多重身份验证,则该函数会在用户通过其第二重身份验证后触发。请注意,在创建新用户时,除了 beforeCreate 之外,还会触发 beforeSignIn

使用屏蔽函数时,请谨记以下几点:

  • 您的函数必须在 7 秒内响应。经过 7 秒之后,Firebase Authentication 会返回错误,并且客户端操作会失败。

  • 200 以外的 HTTP 响应代码会传递到您的客户端应用。请确保您的客户端代码处理函数可能会返回的任何错误。

  • 函数会应用于项目中的所有用户,包括租户中包含的任何用户。Firebase Authentication 为您的函数提供用户相关信息(包括用户所属的任何租户),以便您可以做出相应的响应。

  • 如果将其他身份提供方关联至帐号,则会再度触发所有已注册的 beforeSignIn 函数。

  • 匿名和自定义身份验证不会触发屏蔽函数。

部署并注册屏蔽函数

如需将自定义代码插入到用户身份验证流程中,请部署并注册屏蔽函数。部署并注册屏蔽函数后,自定义代码必须成功完成,身份验证和用户创建操作才能成功。

部署屏蔽函数

您可以像部署任何函数一样部署屏蔽函数。如需了解详情,请参阅 Cloud Functions 使用入门页面。总结:

  1. 编写用于处理 beforeCreate 事件和/或 beforeSignIn 事件的 Cloud Functions 函数。

    例如,如需开始使用,您可以将以下无操作函数添加到 index.js

    const functions = require('firebase-functions');
    
    exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
      // TODO
    });
    
    exports.beforeSignIn = functions.auth.user().beforeSignIn((user, context) => {
      // TODO
    });
    

    上面的示例省略了自定义身份验证逻辑的实现步骤。请参阅以下部分,了解实现屏蔽函数的方法以及特定示例的常见场景

  2. 使用 Firebase CLI 部署您的函数:

    firebase deploy --only functions
    

    每次更新函数时,您都必须重新部署函数。

注册屏蔽函数

  1. 前往 Firebase 控制台中的 Firebase Authentication 设置页面。

  2. 选择屏蔽函数标签页。

  3. 创建帐号之前 (beforeCreate)登录之前 (beforeSignIn)下的下拉菜单中选择屏蔽函数,以注册该函数。

  4. 保存更改。

获取用户和上下文信息

beforeSignInbeforeCreate 事件提供了包含用户登录相关信息的 UserEventContext 对象。在代码中使用这些值可确定是否允许某项操作继续。

如需查看 User 对象的可用属性列表,请参阅 UserRecord API 参考文档

EventContext 对象包含以下属性:

名称 说明 示例
locale 应用所用的语言区域。您可以使用客户端 SDK 设置语言区域,也可以通过在 REST API 中传递语言区域标头来进行设置。 frsv-SE
ipAddress 最终用户用于注册或登录的设备的 IP 地址。 114.14.200.1
userAgent 触发了屏蔽函数的用户代理。 Mozilla/5.0 (X11; Linux x86_64)
eventId 事件的唯一标识符。 rWsyPtolplG2TBFoOkkgyg
eventType 事件类型。提供有关事件名称(例如 beforeSignInbeforeCreate)以及所用登录方法(例如 Google 或电子邮件地址/密码)的信息。 providers/cloud.auth/eventTypes/user.beforeSignIn:password
authType 始终为 USER USER
resource Firebase Authentication 项目或租户。 projects/project-id/tenants/tenant-id
timestamp 事件触发的时间,格式为 RFC 3339 字符串。 Tue, 23 Jul 2019 21:10:57 GMT
additionalUserInfo 一个包含用户相关信息的对象。 AdditionalUserInfo
credential 一个包含用户凭据相关信息的对象。 AuthCredential

屏蔽注册或登录

如需屏蔽注册或登录尝试,请在您的函数中抛出 HttpsError。例如:

Node.js

throw new functions.auth.HttpsError('permission-denied');

下表列出了您可能引发的错误,以及这些错误的默认错误消息:

名称 代码 消息
invalid-argument 400 客户端指定了无效参数。
failed-precondition 400 请求无法在当前系统状态下执行。
out-of-range 400 客户端指定了无效范围。
unauthenticated 401 OAuth 令牌缺失、无效或过期。
permission-denied 403 客户端权限不足。
not-found 404 未找到指定的资源。
aborted 409 并发冲突,例如读取/修改/写入冲突。
already-exists 409 客户端尝试创建的资源已存在。
resource-exhausted 429 资源配额不足或达到了速率限制。
cancelled 499 请求被客户端取消。
data-loss 500 出现了不可恢复的数据丢失或数据损坏问题。
unknown 500 出现未知的服务器错误。
internal 500 内部服务器错误。
not-implemented 501 API 方法未通过服务器实现。
unavailable 503 服务不可用。
deadline-exceeded 504 已超过请求时限。

您还可以指定自定义错误消息:

Node.js

throw new functions.auth.HttpsError('permission-denied', 'Unauthorized request origin!');

以下示例展示了如何阻止不在特定网域内的用户注册您的应用:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  // (If the user is authenticating within a tenant context, the tenant ID can be determined from
  // user.tenantId or from context.resource, e.g. 'projects/project-id/tenant/tenant-id-1')

  // Only users of a specific domain can sign up.
  if (user.email.indexOf('@acme.com') === -1) {
    throw new functions.auth.HttpsError('invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

无论您是使用默认消息还是自定义消息,Cloud Functions 都会封装相应错误,并将其作为内部错误返回给客户端。例如:

throw new functions.auth.HttpsError('invalid-argument', `Unauthorized email user@evil.com}`);

您的应用应捕获错误并相应地进行处理。例如:

JavaScript

// Blocking functions can also be triggered in a multi-tenant context before user creation.
// firebase.auth().tenantId = 'tenant-id-1';
firebase.auth().createUserWithEmailAndPassword('johndoe@example.com', 'password')
  .then((result) => {
    result.user.getIdTokenResult()
  })
  .then((idTokenResult) => {
    console.log(idTokenResult.claim.admin);
  })
  .catch((error) => {
    if (error.code !== 'auth/internal-error' && error.message.indexOf('Cloud Function') !== -1) {
      // Display error.
    } else {
      // Registration succeeds.
    }
  });

修改用户

您可以允许操作继续执行,而不必屏蔽注册或登录尝试,但应修改保存到 Firebase Authentication 数据库并返回给客户端的 User 对象。

如需修改用户,请从事件处理脚本返回包含要修改的字段的对象。您可以修改以下字段:

  • displayName
  • disabled
  • emailVerified
  • photoUrl
  • customClaims
  • sessionClaims(仅限 beforeSignIn

sessionClaims 之外,所有修改后的字段都将保存到 Firebase Authentication 的数据库,这意味着它们会包含在响应令牌中,并在多次用户会话之间继续留存。

以下示例展示了如何设置默认显示名称:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  return {
    // If no display name is provided, set it to "Guest".
    displayName: user.displayName || 'Guest';
  };
});

如果您为 beforeCreatebeforeSignIn 注册了事件处理脚本,请注意 beforeSignIn 会在 beforeCreate 之后执行。在 beforeCreate 中更新的用户字段会显示在 beforeSignIn 中。如果您在两个事件处理脚本中均设置了 sessionClaims 以外的字段,则 beforeSignIn 中设置的值会替换 beforeCreate 中设置的值。仅对于 sessionClaims,这些值会传播到当前会话的令牌声明中,而不会留存或存储在数据库中。

例如,如果设置了任何 sessionClaims,则 beforeSignIn 将在响应中将其随同任何 beforeCreate 声明一起返回,并且会将两者合并在一起。合并后,如果某个 sessionClaims 键与 customClaims 中的某个键匹配,则匹配的 customClaims 将在令牌声明中被 sessionClaims 键所替换。但是,被覆盖的 customClaims 键仍将留存在数据库中,以供将来的请求使用。

支持的 OAuth 凭据和数据

您可以向屏蔽函数传递来自各个身份提供方的 OAuth 凭据和数据。下表显示了各身份提供方支持的凭据和数据:

身份提供方 ID 令牌 访问令牌 到期时间 令牌 Secret 刷新令牌 登录声明
Google 具备
Facebook 不具备 不具备
Twitter 具备 具备 不具备
GitHub 具备 不具备 不具备 不具备
Microsoft 具备
LinkedIn 不具备 不具备
Yahoo 具备
Apple 具备
SAML 不具备 不具备 不具备
OIDC

刷新令牌

如需在屏蔽函数中使用刷新令牌,您必须先选中 Firebase 控制台的屏蔽函数页面上的复选框。

使用 OAuth 凭据(例如 ID 令牌或访问令牌)直接登录时,任何身份提供方都不会返回刷新令牌。在这种情况下,系统会向屏蔽函数传递相同的客户端 OAuth 凭据。

以下部分介绍了每种身份提供方类型及其支持的凭据和数据。

常规 OIDC 提供方

当用户使用常规 OIDC 提供方登录时,系统将传递以下凭据:

  • ID 令牌:在选择了 id_token 流时提供。
  • 访问令牌:在选择了代码流时提供。请注意,目前仅通过 REST API 支持代码流。
  • 刷新令牌:在选择了 offline_access 范围时提供。

示例:

const provider = new firebase.auth.OAuthProvider('oidc.my-provider');
provider.addScope('offline_access');
firebase.auth().signInWithPopup(provider);

Google

当用户使用 Google 帐号登录时,系统将传递以下凭据:

  • ID 令牌
  • 访问令牌
  • 刷新令牌:仅在请求以下自定义参数时提供:
    • access_type=offline
    • prompt=consent(如果用户之前同意并且未请求新的范围)

示例:

const provider = new firebase.auth.GoogleAuthProvider();
provider.setCustomParameters({
  'access_type': 'offline',
  'prompt': 'consent'
});
firebase.auth().signInWithPopup(provider);

详细了解 Google 刷新令牌

Facebook

当用户使用 Facebook 帐号登录时,系统将传递以下凭据:

  • 访问令牌:返回可交换为另一个访问令牌的访问令牌。详细了解 Facebook 支持的不同类型的访问令牌,以及如何用这些令牌交换长期令牌

GitHub

当用户使用 GitHub 帐号登录时,系统将传递以下凭据:

  • 访问令牌:除非撤销,否则该令牌不会过期。

Microsoft

当用户使用 Microsoft 帐号登录时,系统将传递以下凭据:

  • ID 令牌
  • 访问令牌
  • 刷新令牌:如果选择了 offline_access 范围,则将此令牌传递给屏蔽函数。

示例:

const provider = new firebase.auth.OAuthProvider('microsoft.com');
provider.addScope('offline_access');
firebase.auth().signInWithPopup(provider);

Yahoo

当用户使用 Yahoo 帐号登录时,系统将传递以下凭据,但不包含任何自定义参数或范围:

  • ID 令牌
  • 访问令牌
  • 刷新令牌

LinkedIn

当用户使用 LinkedIn 帐号登录时,系统将传递以下凭据:

  • 访问令牌

Apple

当用户使用 Apple 帐号登录时,系统将传递以下凭据,但不包含任何自定义参数或范围:

  • ID 令牌
  • 访问令牌
  • 刷新令牌

常见方案

以下示例展示了屏蔽函数的一些常见使用场景:

仅允许从特定网域注册

以下示例展示了如何屏蔽不属于 example.com 网域成员的用户向您的应用注册:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  if (!user.email || user.email.indexOf('@example.com') === -1) {
    throw new functions.auth.HttpsError(
      'invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

禁止使用未验证电子邮件地址的用户注册

以下示例展示了如何禁止用户使用未经验证的电子邮件地址向您的应用注册:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  if (user.email && !user.emailVerified) {
    throw new functions.auth.HttpsError(
      'invalid-argument', `Unverified email "${user.email}"`);
  }
});

注册时要求进行电子邮件验证

以下示例展示了如何在注册后要求用户验证电子邮件地址:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  const locale = context.locale;
  if (user.email && !user.emailVerified) {
    // Send custom email verification on sign-up.
    return admin.auth().generateEmailVerificationLink(user.email).then((link) => {
      return sendCustomVerificationEmail(user.email, link, locale);
    });
  }
});

exports.beforeSignIn = functions.auth.user().beforeSignIn((user, context) => {
 if (user.email && !user.emailVerified) {
   throw new functions.auth.HttpsError(
     'invalid-argument', `"${user.email}" needs to be verified before access is granted.`);
  }
});

将某些身份提供方电子邮件地址视为已验证

以下示例展示了如何将来自某些身份提供方的用户电子邮件地址视为已验证:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  if (user.email && !user.emailVerified && context.eventType.indexOf(':facebook.com') !== -1) {
    return {
      emailVerified: true,
    };
  }
});

禁止通过特定 IP 地址登录

以下示例展示了如何禁止用户从特定 IP 地址范围登录:

Node.js

exports.beforeSignIn = functions.auth.user().beforeSignIn((user, context) => {
  if (isSuspiciousIpAddress(context.ipAddress)) {
    throw new functions.auth.HttpsError(
      'permission-denied', 'Unauthorized access!');
  }
});

设置自定义声明和会话声明

以下示例展示了如何设置自定义声明和会话声明:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'saml.my-provider-id') {
    return {
      // Employee ID does not change so save in persistent claims (stored in
      // Auth DB).
      customClaims: {
        eid: context.credential.claims.employeeid,
      },
      // Copy role and groups to token claims. These will not be persisted.
      sessionClaims: {
        role: context.credential.claims.role,
        groups: context.credential.claims.groups,
      }
    }
  }
});

跟踪 IP 地址以监控可疑活动

您可以防范令牌盗用,方法是跟踪用户登录时所在的 IP 地址,并将该请求与后续请求的 IP 地址进行比较。如果请求看起来很可疑(例如,IP 地址来自不同的地理区域),您可以要求用户重新登录。

  1. 使用会话声明来跟踪用户登录时使用的 IP 地址:

    Node.js

    exports.beforeSignIn = functions.auth.user().beforeSignIn((user, context) => {
      return {
        sessionClaims: {
          signInIpAddress: context.ipAddress,
        },
      };
    });
    
  2. 当用户尝试访问要求通过 Firebase Authentication 进行身份验证的资源时,将请求中的 IP 地址与其用于登录的 IP 地址进行比对:

    Node.js

    app.post('/getRestrictedData', (req, res) => {
      // Get the ID token passed.
      const idToken = req.body.idToken;
      // Verify the ID token, check if revoked and decode its payload.
      admin.auth().verifyIdToken(idToken, true).then((claims) => {
        // Get request IP address
        const requestIpAddress = req.connection.remoteAddress;
        // Get sign-in IP address.
        const signInIpAddress = claims.signInIpAddress;
        // Check if the request IP address origin is suspicious relative to
        // the session IP addresses. The current request timestamp and the
        // auth_time of the ID token can provide additional signals of abuse,
        // especially if the IP address suddenly changed. If there was a sudden
        // geographical change in a short period of time, then it will give
        // stronger signals of possible abuse.
        if (!isSuspiciousIpAddressChange(signInIpAddress, requestIpAddress)) {
          // Suspicious IP address change. Require re-authentication.
          // You can also revoke all user sessions by calling:
          // admin.auth().revokeRefreshTokens(claims.sub).
          res.status(401).send({error: 'Unauthorized access. Please login again!'});
        } else {
          // Access is valid. Try to return data.
          getData(claims).then(data => {
            res.end(JSON.stringify(data);
          }, error => {
            res.status(500).send({ error: 'Server error!' })
          });
        }
      });
    });
    

过滤用户照片

以下示例展示了如何清理用户的个人资料照片:

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  if (user.photoURL) {
    return isPhotoAppropriate(user.photoURL)
      .then((status) => {
        if (!status) {
          // Sanitize inappropriate photos by replacing them with guest photos.
          // Users could also be blocked from sign-up, disabled, etc.
          return {
            photoUrl: PLACEHOLDER_GUEST_PHOTO_URL,
          };
        }
      });
});

如需详细了解如何检测和清理图片,请参阅 Cloud Vision 文档。

访问用户的身份提供方的 OAuth 凭据

以下示例展示了如何为使用 Google 帐号登录的用户获取刷新令牌,并使用该令牌调用 Google Calendar API。系统会存储刷新令牌以供离线访问时使用。

Node.js

const {OAuth2Client} = require('google-auth-library');
const {google} = require('googleapis');
// ...
// Initialize Google OAuth client.
const keys = require('./oauth2.keys.json');
const oAuth2Client = new OAuth2Client(
  keys.web.client_id,
  keys.web.client_secret
);

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'google.com') {
    // Store the refresh token for later offline use.
    // These will only be returned if refresh tokens credentials are included
    // (enabled by Cloud console).
    return saveUserRefreshToken(
        user.uid,
        context.credential.refreshToken,
        'google.com'
      )
      .then(() => {
        // Blocking the function is not required. The function can resolve while
        // this operation continues to run in the background.
        return new Promise((resolve, reject) => {
          // For this operation to succeed, the appropriate OAuth scope should be requested
          // on sign in with Google, client-side. In this case:
          // https://www.googleapis.com/auth/calendar
          // You can check granted_scopes from within:
          // context.additionalUserInfo.profile.granted_scopes (space joined list of scopes).

          // Set access token/refresh token.
          oAuth2Client.setCredentials({
            access_token: context.credential.accessToken,
            refresh_token: context.credential.refreshToken,
          });
          const calendar = google.calendar('v3');
          // Setup Onboarding event on user's calendar.
          const event = {/** ... */};
          calendar.events.insert({
            auth: oauth2client,
            calendarId: 'primary',
            resource: event,
          }, (err, event) => {
            // Do not fail. This is a best effort approach.
            resolve();
          });
      });
    })
  }
});