创建自定义登录页面

要将外部身份与 Identity-Aware Proxy (IAP) 搭配使用,您的应用需要登录页面。IAP 会将用户重定向到此页面进行身份验证,然后用户才能访问安全资源。

本文介绍了如何从头开始构建身份验证页面。亲自构建此页面非常复杂,但您能够完全控制身份验证流程和用户体验。

如果您不需要完全自定义界面,不妨考虑让 IAP 为您托管登录页面,或改用 FirebaseUI 简化您的代码。

准备工作

启用外部身份,并在设置期间选择我将提供自己的界面选项。

安装 gcip-iap 库

gcip-iap NPM 模块让您无需操心应用、IAP 和 Identity Platform 之间的通信。

强烈建议您使用该库。借助它,您可以自定义整个身份验证流程,而不必担心界面和 IAP 之间的基础交换。

将库作为依赖项包含在内,如下所示:

// Import Firebase/GCIP dependencies. These are installed on npm install.
import * as firebase from 'firebase/app';
import 'firebase/auth';
// Import GCIP/IAP module.
import * as ciap from 'gcip-iap';

实现 AuthenticationHandler

gcip-iap 模块定义了一个名为 AuthenticationHandler 的接口。该库会在适当的时间自动调用其方法来处理身份验证。该接口如下所示:

interface AuthenticationHandler {
  languageCode?: string | null;
  getAuth(apiKey: string, tenantId: string | null): FirebaseAuth;
  startSignIn(auth: FirebaseAuth, match?: SelectedTenantInfo): Promise<UserCredential>;
  selectTenant?(projectConfig: ProjectConfig, tenantIds: string[]): Promise<SelectedTenantInfo>;
  completeSignOut(): Promise<void>;
  processUser?(user: User): Promise<User>;
  showProgressBar?(): void;
  hideProgressBar?(): void;
  handleError?(error: Error | CIAPError): void;
}

要自定义界面,您需要创建一个用于实现该界面的自定义类。对您使用的 JavaScript 框架没有任何限制。以下各部分提供有关如何构建每种方法的其他信息。

选择租户

如果您使用的是多租户,则需要先选择一个租户,然后才能对用户进行身份验证。如果您只有一个租户,或者正在使用项目级层身份验证,则可以跳过此步骤。

IAP 支持的提供商与 Identity Platform 相同,例如:

  • 电子邮件地址和密码
  • OAuth(Google、Facebook、Twitter、GitHub、Microsoft 等)
  • SAML
  • OIDC
  • 电话号码
  • 自定义
  • 匿名

请注意,多租户不支持电话号码、自定义和匿名身份验证。

为了选择租户,库会调用 selectTenant() 回调。实现此方法可以通过编程方式选择租户,或显示界面,以便用户自行选择一个。

如需以编程方式选择租户,请利用当前上下文。Authentication 类包含一个 getOriginalURL() 方法,该方法会返回用户在需要进行身份验证之前正访问的网址。您可以用它从关联的租户列表中找到匹配项。

// Select provider programmatically.
selectTenant(projectConfig, tenantIds) {
  return new Promise((resolve, reject) => {
    // Show UI to select the tenant.
    auth.getOriginalURL()
      .then((originalUrl) => {
        resolve({
          tenantId: getMatchingTenantBasedOnVisitedUrl(originalUrl),
          // If associated provider IDs can also be determined,
          // populate this list.
          providerIds: [],
        });
      })
      .catch(reject);
  });
}

如果您选择显示界面,则可以通过多种方法来确定要使用的提供商。例如,您可以显示一个租户列表,让用户选择一个,也可以要求他们输入自己的电子邮件地址,然后根据网域查找匹配项。

// Select provider by showing UI.
selectTenant(projectConfig, tenantIds) {
  return new Promise((resolve, reject) => {
    // Show UI to select the tenant.
    renderSelectTenant(
        tenantIds,
        // On tenant selection.
        (selectedTenantId) => {
          resolve({
            tenantId: selectedTenantId,
            // If associated provider IDs can also be determined,
            // populate this list.
            providerIds: [],
            // If email is available, populate this field too.
            email: undefined,
          });
        });
  });
}

无论采用哪种方法,返回的 SelectedTenantInfo 对象都将在之后用于完成身份验证流程。它包含所选租户的 ID、所有提供商 ID 以及用户输入的电子邮件地址。

获取 Auth 对象

一旦有了提供商,就需要一种获取 Auth 对象的方法。实现 getAuth() 回调以返回与提供的 API 密钥和租户 ID 对应的 firebase.auth.Auth 实例。如果未提供任何租户 ID,则应改用项目级层身份提供商。

getAuth() 用于跟踪与提供的配置对应的用户的存储位置。它还使得可以静默刷新先前已认证的用户的 Identity Platform ID 令牌,而无需用户重新输入其凭据。

如果您将多个 IAP 资源用于不同的租户,我们建议您为每个租户使用唯一的 Auth 实例。这允许具有不同配置的多个资源使用同一身份验证页面。它还允许多个用户同时登录而无需退出前一个用户。

以下是一个有关如何实现 getAuth() 的示例:

getAuth(apiKey, tenantId) {
  let auth = null;
  // Make sure the expected API key is being used.
  if (apiKey !== expectedApiKey) {
    throw new Error('Invalid project!');
  }
  try {
    auth = firebase.app(tenantId || undefined).auth();
    // Tenant ID should be already set on initialization below.
  } catch (e) {
    // Use different App names for every tenant. This makes it possible to have
    // multiple users signed in at the same time (one per tenant).
    const app = firebase.initializeApp(this.config, tenantId || '[DEFAULT]');
    auth = app.auth();
    // Set the tenant ID on the Auth instance.
    auth.tenantId = tenantId || null;
  }
  return auth;
}

让用户登录

如需处理登录,请实现 startSignIn() 回调。它应显示一个供用户进行身份验证的界面,然后在完成时为已登录的用户返回 UserCredential

在多租户环境中,可以根据 SelectedTenantInfo(如果已提供)确定可用的身份验证方法。该变量包含由 selectTenant() 回调返回的相同信息。

以下示例展示了如何使用电子邮件和密码为现有用户实现 startSignIn()

startSignIn(auth, selectedTenantInfo) {
  return new Promise((resolve, reject) => {
    // Show UI to sign-in or sign-up a user.
    $('#sign-in-form').on('submit', (e) => {
      const email = $('#email').val();
      const password = $('#password').val();
      // Example: ask user for email and password.
      // The method of sign in may have already been determined from the
      // selectedTenantInfo object.
      auth.signInWithEmailAndPassword(email, password)
        .then((userCredential) => {
          resolve(userCredential);
        })
        .catch((error) => {
          // Show error message.
        });
    });
  });
}

您还可以用弹出式窗口或重定向通过联盟提供商(例如 SAML 或 OIDC)让用户登录:

startSignIn(auth, selectedTenantInfo) {
  // Show UI to sign-in or sign-up a user.
  return new Promise((resolve, reject) => {
    // Provide user multiple buttons to sign-in.
    // For example sign-in with popup using a SAML provider.
    // The method of sign in may have already been determined from the
    // selectedTenantInfo object.
    const provider = new firebase.auth.SAMLAuthProvider('saml.myProvider');
    auth.signInWithPopup(provider)
      .then((userCredential) => {
        resolve(userCredential);
      })
      .catch((error) => {
        // Show error message.
       });
    // Using redirect flow. When the page redirects back and sign-in completes,
    // ciap will detect the result and complete sign-in without any additional
    // action.
    auth.signInWithRedirect(provider);
  });
}

一些 OAuth 提供商支持传递登录提示以进行登录:

startSignIn(auth, selectedTenantInfo) {
  // Show UI to sign-in or sign-up a user.
  return new Promise((resolve, reject) => {
    // Use selectedTenantInfo to determine the provider and pass the login hint
    // if that provider supports it and the user specified an email.
    if (selectedTenantInfo &&
        selectedTenantInfo.providerIds &&
        selectedTenantInfo.providerIds.indexOf('microsoft.com') !== -1) {
      const provider = new firebase.auth.OAuthProvider('microsoft.com');
      provider.setCustomParameters({
        login_hint: selectedTenantInfo.email || undefined,
      });
    } else {
      // Figure out the provider used...
    }
    auth.signInWithPopup(provider)
      .then((userCredential) => {
        resolve(userCredential);
      })
      .catch((error) => {
        // Show error message.
       });
    });
}

请参阅 Identity Platform 文档,了解如何对用户进行身份验证。

处理用户

可选的 processUser() 方法允许您在重定向回 IAP 资源之前修改已登录的用户。使用此方法,您可以:

  • 链接到其他提供商。
  • 更新用户的个人资料。
  • 注册后要求用户提供其他数据。
  • 调用 signInWithRedirect() 之后,处理 getRedirectResult() 返回的 OAuth 访问令牌。

以下是实现 processUser() 的示例:

processUser(user) {
  return lastAuthUsed.getRedirectResult().then(function(result) {
    // Save additional data, or ask user for additional profile information
    // to store in database, etc.
    if (result) {
      // Save result.additionalUserInfo.
      // Save result.credential.accessToken for OAuth provider, etc.
    }
    // Return the user.
    return user;
  });
}

请注意,如果要对用户进行任何更改,以反映在由 IAP 传播到您的应用的 ID 令牌声明中,则需要强制刷新令牌:

processUser(user) {
  return user.updateProfile({
    photoURL: 'https://example.com/profile/1234/photo.png',
  }).then(function() {
    // To reflect updated photoURL in the ID token, force token
    // refresh.
    return user.getIdToken(true);
  }).then(function() {
    return user;
  });
}

显示进度界面

实现可选的 showProgressBar()hideProgressBar() 回调,以在 gcip-iap 模块执行长时间运行的网络任务时向用户显示自定义进度界面。

处理错误

handleError() 是用于错误处理的可选回调。实现它可以向用户显示错误消息,或尝试从某些错误(例如网络超时)中恢复。

以下示例展示了如何实现 handleError()

handleError(error) {
  showAlert({
    code: error.code,
    message: error.message,
    // Whether to show the retry button. This is only available if the error is
    // recoverable via retrial.
    retry: !!error.retry,
  });
  // When user clicks retry, call error.retry();
  $('.alert-link').on('click', (e) => {
    error.retry();
    e.preventDefault();
    return false;
  });
}

下表列出了可能抛出的特定于 IAP 的错误代码。Identity Platform 也可能抛出错误;请参阅 firebase.auth.Auth 的文档。

错误代码 说明
invalid-argument 客户端指定了无效参数。
failed-precondition 请求无法在当前系统状态下执行。
out-of-range 客户端指定了无效范围。
unauthenticated 由于缺少、无效或过期的 OAuth 令牌,请求未通过身份验证。
permission-denied 客户端没有足够的权限,或者界面托管在未经授权的网域中。
not-found 未找到指定的资源。
aborted 并发冲突,例如读取/修改/写入冲突。
already-exists 客户端尝试创建的资源已存在。
resource-exhausted 资源配额不足或达到了速率限制。
cancelled 请求被客户端取消。
data-loss 出现了不可恢复的数据丢失或数据损坏问题。
unknown 出现未知的服务器错误。
internal 内部服务器错误。
not-implemented API 方法未通过服务器实现。
unavailable 服务不可用。
restart-process 重新访问将您重定向到此页面的网址,以重新启动身份验证流程。
deadline-exceeded 已超过请求时限。
authentication-uri-fail 未生成身份验证 URI。
gcip-token-invalid 提供了无效的 GCIP ID 令牌。
gcip-redirect-invalid 无效的重定向网址。
get-project-mapping-fail 未获取项目 ID。
gcip-id-token-encryption-error GCIP ID 令牌加密错误。
gcip-id-token-decryption-error GCIP ID 令牌解密错误。
gcip-id-token-unescape-error Web 安全 base64 unescape 失败。
resource-missing-gcip-sign-in-url 指定的 IAP 资源缺少 GCIP 身份验证网址。

让用户退出帐号

在某些情况下,您可能希望允许用户从共享相同身份验证网址的所有当前会话中退出。

用户退出后,可能没有将其重定向回的网址(当用户从与登录页面关联的所有租户中退出时,通常会发生这种情况)。在这种情况下,请实现 completeSignOut() 回调以显示一条消息,指示用户已成功退出。否则,将出现空白页。

使用自定义界面

一旦创建用于实现 AuthenticationHandler 的类,您就可以使用它来创建新的 Authentication 实例并启动它。

// Implement interface AuthenticationHandler.
// const authHandlerImplementation = ....
const ciapInstance = new ciap.Authentication(authHandlerImplementation);
ciapInstance.start();

部署您的应用并转到到身份验证页面。您应该会看到自定义登录界面。

后续步骤