使用屏蔽函数自定义身份验证流程

本文档介绍如何使用 Cloud Functions 屏蔽函数扩展 Identity Platform 身份验证。

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

准备工作

使用 Identity Platform 创建一个应用。如需了解具体方法,请参阅快速入门

了解屏蔽函数

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

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

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

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

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

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

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

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

  • 匿名和自定义身份验证不支持屏蔽函数。

创建屏蔽函数

以下步骤演示了如何创建屏蔽函数:

  1. 转到 Cloud Console 中的 Identity Platform 设置页面。

    转到“设置”页面

  2. 选择触发器标签页。

  3. 要创建用于用户注册的屏蔽函数,请选择创建之前 (beforeCreate) 下的函数下拉列表,然后点击创建函数。要创建用于用户登录的屏蔽函数,请在登录之前 (beforeSignIn) 下创建一个函数。

  4. 创建新函数:

    1. 输入函数的名称

    2. 触发器字段中,选择 HTTP

    3. 身份验证字段中,选择允许未通过身份验证的调用

    4. 点击下一步

  5. 使用内嵌编辑器打开 index.js。删除 helloWorld 示例代码,并将其替换为以下代码段之一:

    要响应注册,请替换为:

    import * as gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
      // TODO
    });
    

    要响应登录,请替换为:

    import * as gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
      // TODO
    });
    
  6. 打开 package.json 并添加以下代码:

    "dependencies": {
      "gcip-cloud-functions": "^0.0.1"
    }
    
  7. 点击部署以发布您的函数。

  8. 点击 Identity Platform 的“屏蔽函数”页面上的保存

如需了解如何实现函数,请参阅以下部分。每次更新函数时,您都必须重新部署该函数。

您还可以使用 gcloud 命令行工具或 REST API 创建和管理函数。请参阅 Cloud Functions 文档,了解如何使用 gcloud 命令行工具部署函数。

获取用户和上下文信息

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 Identity Platform 项目或租户。 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 gcipCloudFunctions.https.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 gcipCloudFunctions.https.HttpsError('permission-denied', 'Unauthorized request origin!');

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

Node.js

// Import the Cloud Auth Admin module.
const gcipCloudFunctions = require('gcip-cloud-functions');
// Initialize the Auth client.
const authClient = new gcipCloudFunctions.Auth();
// Http trigger with Cloud Functions.
exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  // If the user is authenticating within a tenant context, the tenant ID can be determined from
  // user.tenantId or from context.resource, eg. '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 gcipCloudFunctions.https.HttpsError('invalid-argument', `Unauthorized email "${user.email}"`);
});

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

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

您的客户端应用会返回类似于下面这样的错误(如果您使用的是客户端 SDK,则此错误会被封装为内部错误):

{
  "error": {
    "code": 400,
    "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error. Code: 400, Status: \"INVALID_ARGUMENT\", Message: \"Unauthorized email user@evil.com\"",
    "errors": [
      {
        "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error. Code: 400, Status: \"INVALID_ARGUMENT\", Message: \"Unauthorized email user@evil.com\"",
        "domain": "global",
        "reason": "invalid"
      }
    ]
  }
}

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

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.
    }
  });

修改用户

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

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

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

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

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

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((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 令牌 Access Token 失效时间 令牌 Secret 刷新令牌 登录声明
Google
Facebook
Twitter
GitHub
Microsoft
领英
Yahoo
Apple
SAML
OIDC

刷新令牌

要在屏蔽函数中使用刷新令牌,您必须首先进入 Cloud Console,在包含令牌凭据下拉菜单中的触发器部分内选中复选框。

使用 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 令牌
  • 访问令牌
  • 刷新令牌

领英

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

  • 访问令牌

Apple

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

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

常见方案

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

仅允许从特定网域注册

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

Node.js

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

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

下面的示例说明了如何禁止用户使用未经注册的电子邮件地址向您的应用注册:

Node.js

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

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

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

Node.js

export.beforeCreate = authClient.functions().beforeCreateHandler((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);
    });
  }
});

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

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

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

Node.js

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

禁止通过特定 IP 地址登录

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

Node.js

export.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
  if (isSuspiciousIpAddress(context.ipAddress)) {
    throw new gcipCloudFunctions.https.HttpsError(
      'permission-denied', 'Unauthorized access!');
  }
});

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

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

Node.js

export.beforeSignIn = authClient.functions().beforeCreateHandler((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

    export.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
      return {
        sessionClaims: {
          signInIpAddress: context.ipAddress,
        },
      };
    });
    
  2. 当用户尝试访问需要通过 Identity Platform 进行身份验证的资源时,请将请求中的 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

export.beforeCreate = authClient.functions().beforeCreateHandler((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
);

export.beforeCreate = authClient.functions().beforeCreateHandler((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();
          });
      });
    })
  }
});

后续步骤