教程:Cloud Run 最终用户身份验证

本教程介绍如何创建投票服务,其中包括:

  • 基于浏览器的客户端,该客户端具有以下功能:

    1. 使用 Identity Platform 提取 ID 令牌。
    2. 允许用户为自己喜欢的家养动物投票。
    3. 将该 ID 令牌添加到向处理投票的 Cloud Run 服务器发出的请求中。
  • Cloud Run 服务器,该服务器具有以下功能:

    1. 通过提供有效的 ID 令牌进行检查以确保最终用户已正确进行身份验证。
    2. 处理最终用户的投票。
    3. 使用其自己的凭据,将投票发送到 Cloud SQL 进行存储。
  • 存储投票的 PostgresSQL 数据库。

为简单起见,本教程使用 Google 作为提供商:用户必须使用 Google 账号进行身份验证才能获取其 ID 令牌。但是,您可以使用其他提供商或身份验证方法来登录用户

在本文档中,术语“用户账号”是指由您的身份提供方管理并通过员工身份联合进行联合的用户账号。

您可以使用用户账号提供的凭据登录该工具。

为了将安全风险降至最低,此服务使用 Secret Manager 来保护用于关联到 Cloud SQL 实例的敏感数据。它还使用最小权限服务身份来保护对数据库的访问权限。

目标

编写、构建服务并将其部署到 Cloud Run,该服务展示如何:

  • 使用 Identity Platform 向 Cloud Run 服务后端对最终用户进行身份验证。

  • 为该服务创建最小权限身份,以便仅授予对 Google Cloud 资源的最低访问权限。

  • 将 Cloud Run 服务连接到 PostgreSQL 数据库时,使用 Secret Manager 处理敏感数据。

费用

在本文档中,您将使用 Google Cloud的以下收费组件:

如需根据您的预计使用量来估算费用,请使用价格计算器

新 Google Cloud 用户可能有资格申请免费试用

准备工作

  1. In the Google Cloud console, on the project selector page, select or create a Google Cloud project.

    Roles required to select or create a project

    • Select a project: Selecting a project doesn't require a specific IAM role—you can select any project that you've been granted a role on.
    • Create a project: To create a project, you need the Project Creator role (roles/resourcemanager.projectCreator), which contains the resourcemanager.projects.create permission. Learn how to grant roles.

    Go to project selector

  2. Verify that billing is enabled for your Google Cloud project.

  3. Enable the Cloud Run, Secret Manager, Cloud SQL, Artifact Registry, and Cloud Build APIs.

    Roles required to enable APIs

    To enable APIs, you need the Service Usage Admin IAM role (roles/serviceusage.serviceUsageAdmin), which contains the serviceusage.services.enable permission. Learn how to grant roles.

    Enable the APIs

  4. 所需的角色

    如需获得完成本教程所需的权限,请让您的管理员为您授予项目的以下 IAM 角色:

    如需详细了解如何授予角色,请参阅管理对项目、文件夹和组织的访问权限

    您也可以通过自定义角色或其他预定义角色来获取所需的权限。

设置 gcloud 默认值

如需为您的 Cloud Run 服务配置 gcloud 默认值,请执行以下操作:

  1. 设置默认项目:

    gcloud config set project PROJECT_ID

    PROJECT_ID 替换为您在本教程中创建的项目的名称。

  2. 为您选择的区域配置 gcloud:

    gcloud config set run/region REGION

    REGION 替换为您选择的受支持的 Cloud Run 区域

Cloud Run 位置

Cloud Run 是区域级的,这意味着运行 Cloud Run 服务的基础架构位于特定区域,并且由 Google 代管,以便在该区域内的所有可用区以冗余方式提供。

选择用于运行 Cloud Run 服务的区域时,主要考虑该区域能否满足您的延迟时间、可用性或耐用性要求。通常,您可以选择距离用户最近的区域,但除此之外,您还应该考虑 Cloud Run 服务使用的其他 Google Cloud产品的位置。跨多个位置使用 Google Cloud 产品可能会影响服务的延迟时间和费用。

Cloud Run 可在以下区域使用:

基于层级 1 价格

基于层级 2 价格

  • africa-south1(约翰内斯堡)
  • asia-east2(香港)
  • asia-northeast3(韩国首尔)
  • asia-southeast1(新加坡)
  • asia-southeast2 (雅加达)
  • asia-south2(印度德里)
  • australia-southeast1(悉尼)
  • australia-southeast2(墨尔本)
  • europe-central2(波兰,华沙)
  • europe-west10(柏林)
  • europe-west12(都灵)
  • europe-west2(英国伦敦) 叶形图标 二氧化碳排放量低
  • europe-west3(德国法兰克福)
  • europe-west6(瑞士苏黎世) 叶形图标 二氧化碳排放量低
  • me-central1(多哈)
  • me-central2(达曼)
  • northamerica-northeast1(蒙特利尔) 叶形图标 二氧化碳排放量低
  • northamerica-northeast2(多伦多) 叶形图标 二氧化碳排放量低
  • southamerica-east1(巴西圣保罗) 叶形图标 二氧化碳排放量低
  • southamerica-west1(智利圣地亚哥) 叶形图标 二氧化碳排放量低
  • us-west2(洛杉矶)
  • us-west3(盐湖城)
  • us-west4(拉斯维加斯)

如果您已创建 Cloud Run 服务,可在Google Cloud 控制台的 Cloud Run 信息中心内查看区域。

检索代码示例

如需检索可用的代码示例,请执行以下操作:

  1. 将示例应用代码库克隆到本地机器:

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    或者,您也可以下载该示例的 zip 文件并将其解压缩。

    Python

    git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git

    或者,您也可以下载该示例的 zip 文件并将其解压缩。

    Java

    git clone https://github.com/GoogleCloudPlatform/java-docs-samples.git

    或者,您也可以下载该示例的 zip 文件并将其解压缩。

  2. 切换到包含 Cloud Run 示例代码的目录:

    Node.js

    cd nodejs-docs-samples/run/idp-sql/

    Python

    cd python-docs-samples/run/idp-sql/

    Java

    cd java-docs-samples/run/idp-sql/

直观呈现架构

架构图
上图展示了最终用户通过 Identity Platform 提供的 Google 登录对话框登录,然后以用户的身份重定向回 Cloud Run。
  1. 最终用户向 Cloud Run 服务器发出第一个请求。

  2. 客户端会在浏览器中加载。

  3. 用户通过 Identity Platform 中的 Google 登录对话框提供登录凭据。系统会显示提醒来欢迎已登录用户。

  4. 控制重定向回服务器。最终用户使用客户端进行投票,该客户端从 Identity Platform 提取 ID 令牌并将其添加到投票请求标头中。

  5. 服务器收到请求后,会验证 Identity Platform ID 令牌,确认最终用户已经过适当的身份验证。然后服务器使用自己的凭据将投票发送到 Cloud SQL。

了解核心代码

此示例以客户端和服务器的形式实现,如下文所述。

与 Identity Platform 集成:客户端代码

此示例使用 Firebase SDK 与 Identity Platform 集成,以便登录和管理用户。为了连接到 Identity Platform,客户端 JavaScript 会将对项目凭据的引用保存为配置对象,并导入必要的 Firebase JavaScript SDK

const config = {
  apiKey: 'API_KEY',
  authDomain: 'PROJECT_ID.firebaseapp.com',
};
<!-- Firebase App (the core Firebase SDK) is always required and must be listed first-->
<script src="https://www.gstatic.com/firebasejs/7.18/firebase-app.js"></script>
<!-- Add Firebase Auth service-->
<script src="https://www.gstatic.com/firebasejs/7.18/firebase-auth.js"></script>

Firebase JavaScript SDK 处理登录流程,方式如下:通过弹出式窗口提示最终用户登录其 Google 账号。然后,它将用户重定向回该服务。

function signIn() {
  const provider = new firebase.auth.GoogleAuthProvider();
  provider.addScope('https://www.googleapis.com/auth/userinfo.email');
  firebase
    .auth()
    .signInWithPopup(provider)
    .then(result => {
      // Returns the signed in user along with the provider's credential
      console.log(`${result.user.displayName} logged in.`);
      window.alert(`Welcome ${result.user.displayName}!`);
    })
    .catch(err => {
      console.log(`Error during sign in: ${err.message}`);
      window.alert('Sign in failed. Retry or check your browser logs.');
    });
}

用户成功登录后,客户端会使用 Firebase 方法创建 ID 令牌。客户端将 ID 令牌添加到其向服务器的请求的 Authorization 标头中。

async function vote(team) {
  if (firebase.auth().currentUser) {
    // Retrieve JWT to identify the user to the Identity Platform service.
    // Returns the current token if it has not expired. Otherwise, this will
    // refresh the token and return a new one.
    try {
      const token = await firebase.auth().currentUser.getIdToken();
      const response = await fetch('/', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Bearer ${token}`,
        },
        body: 'team=' + team, // send application data (vote)
      });
      if (response.ok) {
        const text = await response.text();
        window.alert(text);
        window.location.reload();
      }
    } catch (err) {
      console.log(`Error when submitting vote: ${err}`);
      window.alert('Something went wrong... Please try again!');
    }
  } else {
    window.alert('User not signed in.');
  }
}

与 Identity Platform 集成:服务器端代码

服务器使用 Firebase Admin SDK 验证从客户端发送的用户 ID 令牌。如果提供的 ID 令牌格式正确、未过期且经过正确签名,则该方法会返回已解码的 ID 令牌。服务器会提取该用户的 Identity Platform uid

Node.js

const firebase = require('firebase-admin');
// Initialize Firebase Admin SDK
firebase.initializeApp();

// Extract and verify Id Token from header
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (authHeader) {
    const token = authHeader.split(' ')[1];
    // If the provided ID token has the correct format, is not expired, and is
    // properly signed, the method returns the decoded ID token
    firebase
      .auth()
      .verifyIdToken(token)
      .then(decodedToken => {
        const uid = decodedToken.uid;
        req.uid = uid;
        next();
      })
      .catch(err => {
        req.logger.error(`Error with authentication: ${err}`);
        return res.sendStatus(403);
      });
  } else {
    return res.sendStatus(401);
  }
};

Python

def jwt_authenticated(func: Callable[..., int]) -> Callable[..., int]:
    """Use the Firebase Admin SDK to parse Authorization header to verify the
    user ID token.

    The server extracts the Identity Platform uid for that user.
    """

    @wraps(func)
    def decorated_function(*args: a, **kwargs: a) -> a:
        header = request.headers.get("Authorization", None)
        if header:
            token = header.split(" ")[1]
            try:
                decoded_token = firebase_admin.auth.verify_id_token(token)
            except Exception as e:
                logger.exception(e)
                return Response(status=403, response=f"Error with authentication: {e}")
        else:
            return Response(status=401)

        request.uid = decoded_token["uid"]
        return func(*args, **kwargs)

    return decorated_function

Java

/** Extract and verify Id Token from header */
private String authenticateJwt(Map<String, String> headers) {
  String authHeader =
      (headers.get("authorization") != null)
          ? headers.get("authorization")
          : headers.get("Authorization");
  if (authHeader != null) {
    String idToken = authHeader.split