教程: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. Sign in to your Google Cloud account. If you're new to Google Cloud, create an account to evaluate how our products perform in real-world scenarios. New customers also get $300 in free credits to run, test, and deploy workloads.
  2. In the Google Cloud console, on the project selector page, select or create a Google Cloud project.

    Go to project selector

  3. Make sure that billing is enabled for your Google Cloud project.

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

    Go to project selector

  5. Make sure that billing is enabled for your Google Cloud project.

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

    Enable the APIs

所需的角色

如需获得完成本教程所需的权限,请让您的管理员为您授予项目的以下 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 价格

如果您已创建 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(" ")[1];
    // If the provided ID token has the correct format, is not expired, and is
    // properly signed, the method returns the decoded ID token
    try {
      FirebaseToken decodedToken = FirebaseAuth.getInstance().verifyIdToken(idToken);
      String uid = decodedToken.getUid();
      return uid;
    } catch (FirebaseAuthException e) {
      logger.error("Error with authentication: " + e.toString());
      throw new ResponseStatusException(HttpStatus.FORBIDDEN, "", e);
    }
  } else {
    logger.error("Error no authorization header");
    throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
  }
}

将服务器连接到 Cloud SQL

服务器按照以下格式连接到 Cloud SQL 实例 Unix 网域套接字:/cloudsql/CLOUD_SQL_CONNECTION_NAME

Node.js

/**
 * Connect to the Cloud SQL instance through UNIX Sockets
 *
 * @param {object} credConfig The Cloud SQL connection configuration from Secret Manager
 * @returns {object} Knex's PostgreSQL client
 */
const connectWithUnixSockets = async credConfig => {
  const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql';
  // Establish a connection to the database
  return Knex({
    client: 'pg',
    connection: {
      user: credConfig.DB_USER, // e.g. 'my-user'
      password: credConfig.DB_PASSWORD, // e.g. 'my-user-password'
      database: credConfig.DB_NAME, // e.g. 'my-database'
      host: `${dbSocketPath}/${credConfig.CLOUD_SQL_CONNECTION_NAME}`,
    },
    ...config,
  });
};

Python

def init_unix_connection_engine(
    db_config: dict[str, int]
) -> sqlalchemy.engine.base.Engine:
    """Initializes a Unix socket connection pool for a Cloud SQL instance of PostgreSQL.

    Args:
        db_config: a dictionary with connection pool config

    Returns:
        A SQLAlchemy Engine instance.
    """
    creds = credentials.get_cred_config()
    db_user = creds["DB_USER"]
    db_pass = creds["DB_PASSWORD"]
    db_name = creds["DB_NAME"]
    db_socket_dir = creds.get("DB_SOCKET_DIR", "/cloudsql")
    cloud_sql_connection_name = creds["CLOUD_SQL_CONNECTION_NAME"]

    pool = sqlalchemy.create_engine(
        # Equivalent URL:
        # postgres+pg8000://<db_user>:<db_pass>@/<db_name>
        #                         ?unix_sock=<socket_path>/<cloud_sql_instance_name>/.s.PGSQL.5432
        sqlalchemy.engine.url.URL.create(
            drivername="postgresql+pg8000",
            username=db_user,  # e.g. "my-database-user"
            password=db_pass,  # e.g. "my-database-password"
            database=db_name,  # e.g. "my-database-name"
            query={
                "unix_sock": f"{db_socket_dir}/{cloud_sql_connection_name}/.s.PGSQL.5432"
                # e.g. "/cloudsql", "<PROJECT-NAME>:<INSTANCE-REGION>:<INSTANCE-NAME>"
            },
        ),
        **db_config,
    )
    pool.dialect.description_encoding = None
    logger.info("Database engine initialized from unix connection")

    return pool

Java

借助 Spring Cloud Google Cloud PostgreSQL 入门版集成,通过 Spring JDBC 库与 Cloud SQL 中的 PostgreSQL 数据库进行交互。将您的 Cloud SQL for MySQL 配置设置为自动配置 DataSource bean;该 bean 与 Spring JDBC 一起提供 JdbcTemplate 对象 bean 以允许查询和修改数据库等操作。

# Uncomment and add env vars for local development
# spring.datasource.username=${DB_USER}
# spring.datasource.password=${DB_PASSWORD}
# spring.cloud.gcp.sql.database-name=${DB_NAME}
# spring.cloud.gcp.sql.instance-connection-name=${CLOUD_SQL_CONNECTION_NAME}  
private final JdbcTemplate jdbcTemplate;

public VoteController(JdbcTemplate jdbcTemplate) {
  this.jdbcTemplate = jdbcTemplate;
}

使用 Secret Manager 处理敏感配置

Secret Manager 为敏感数据提供集中安全存储,例如 Cloud SQL 配置。服务器在运行时通过环境变量从 Secret Manager 注入 Cloud SQL 凭据。详细了解如何将 Secret与 Cloud Run 搭配使用。

Node.js

// CLOUD_SQL_CREDENTIALS_SECRET is the resource ID of the secret, passed in by environment variable.
// Format: projects/PROJECT_ID/secrets/SECRET_ID/versions/VERSION
const {CLOUD_SQL_CREDENTIALS_SECRET} = process.env;
if (CLOUD_SQL_CREDENTIALS_SECRET) {
  try {
    // Parse the secret that has been added as a JSON string
    // to retrieve database credentials
    return JSON.parse(CLOUD_SQL_CREDENTIALS_SECRET.toString('utf8'));
  } catch (err) {
    throw Error(
      `Unable to parse secret from Secret Manager. Make sure that the secret is JSON formatted: ${err}`
    );
  }
}

Python

def get_cred_config() -> dict[str, str]:
    """Retrieve Cloud SQL credentials stored in Secret Manager
    or default to environment variables.

    Returns:
        A dictionary with Cloud SQL credential values
    """
    secret = os.environ.get("CLOUD_SQL_CREDENTIALS_SECRET")
    if secret:
        return json.loads(secret)

Java

/** Retrieve config from Secret Manager */
public static HashMap<String, Object> getConfig() {
  String secret = System.getenv("CLOUD_SQL_CREDENTIALS_SECRET");
  if (secret == null) {
    throw new IllegalStateException("\"CLOUD_SQL_CREDENTIALS_SECRET\" is required.");
  }
  try {
    HashMap<String, Object> config = new Gson().fromJson(secret, HashMap.class);
    return config;
  } catch (JsonSyntaxException e) {
    logger.error(
        "Unable to parse secret from Secret Manager. Make sure that it is JSON formatted: "
            + e);
    throw new RuntimeException(
        "Unable to parse secret from Secret Manager. Make sure that it is JSON formatted.");
  }
}

设置 Identity Platform

您需要在 Google Cloud 控制台中手动设置 Identity Platform。

  1. 转到 Google Cloud 控制台中的 Identity Platform Marketplace 页面。

    转到 Identity Platform Marketplace 页面

  2. 点击启用 Identity Platform

  3. 创建 OAuth 权限请求页面:

    1. 在新窗口中,转到“API 和服务 > 凭据”页面

      依次转到 API 和服务 > 凭据页面。

    2. 选择 OAuth 权限请求屏幕页面。

    3. 出于测试目的,请选择外部

    4. 点击创建

    5. 应用信息对话框中,

      1. 提供应用名称。
      2. 选择显示的用户支持电子邮件地址之一。
      3. 输入您要用于联系开发者的电子邮件地址。
    6. 点击保存并继续

    7. 范围对话框中,点击保存并继续

    8. 测试用户对话框中,点击保存并继续

    9. 摘要对话框中,点击返回信息中心

    10. 发布状态下,点击发布应用

    11. 点击确认

  4. 创建并获取 OAuth 客户端 ID 和 Secret:

    1. 依次转到 API 和服务 > 凭据页面。

    2. 点击页面顶部的创建凭据,然后选择 OAuth client ID

    3. 应用类型中,选择 Web 应用并提供名称。

    4. 点击创建

    5. 记下 client_idclient_secret,以便稍后在此过程中使用。

  5. 将 Google 配置为提供商:

    1. 转到 Cloud 控制台中的“身份提供商”页面。

      转到“身份提供商”页面

    2. 点击添加提供商

    3. 从列表中选择 Google

    4. 在 Web SDK 配置设置中,输入上述步骤中的 client_idclient_secret 值。

    5. 配置您的应用下,点击设置详细信息

    6. apiKeyauthDomain 值复制到示例的 static/config.js 中,以初始化 Identity Platform 客户端 SDK。

    7. 点击保存

部署服务

请按照以下步骤完成基础架构预配和部署,或者通过点击“在 Google Cloud 上运行”,在 Cloud Shell 中自动执行此过程:

在 Google Cloud 上运行

  1. 使用控制台或 CLI 创建使用 postgreSQL 数据库的 Cloud SQL 实例:

    gcloud sql instances create CLOUD_SQL_INSTANCE_NAME \
        --database-version=POSTGRES_12 \
        --region=CLOUD_SQL_REGION \
        --cpu=2 \
        --memory=7680MB \
        --root-password=DB_PASSWORD
  2. 将您的 Cloud SQL 凭据值添加到 postgres-secrets.json

    Node.js

    {
      "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE",
      "DB_NAME": "postgres",
      "DB_USER": "postgres",
      "DB_PASSWORD": "PASSWORD_SECRET"
    }
    

    Python

    {
      "CLOUD_SQL_CONNECTION_NAME": "PROJECT_ID:REGION:INSTANCE",
      "DB_NAME": "postgres",
      "DB_USER": "postgres",
      "DB_PASSWORD": "PASSWORD_SECRET"
    }
    

    Java

    {
      "spring.cloud.gcp.sql.instance-connection-name": "PROJECT_ID:REGION:INSTANCE",
      "spring.cloud.gcp.sql.database-name": "postgres",
      "spring.datasource.username": "postgres",
      "spring.datasource.password": "PASSWORD_SECRET"
    }

  3. 使用控制台或 CLI 创建受版本控制的密钥:

    gcloud secrets create idp-sql-secrets \
        --replication-policy="automatic" \
        --data-file=postgres-secrets.json
  4. 使用控制台或 CLI 为服务器创建服务账号:

    gcloud iam service-accounts create idp-sql-identity
  5. 使用控制台或 CLI 为访问 Secret Manager 和 Cloud SQL 授予角色:

    1. 允许与服务器关联的服务账号访问创建的 Secret:

      gcloud secrets add-iam-policy-binding idp-sql-secrets \
        --member serviceAccount:idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/secretmanager.secretAccessor
    2. 允许与服务器关联的服务账号访问 Cloud SQL:

      gcloud projects add-iam-policy-binding PROJECT_ID \
        --member serviceAccount:idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --role roles/cloudsql.client
  6. 创建 Artifact Registry:

    gcloud artifacts repositories create REPOSITORY \
        --repository-format docker \
        --location REGION
    • REPOSITORY 是代码库的名称。对于项目中的每个代码库位置,代码库名称不得重复。
  7. 使用 Cloud Build 构建容器映像:

    Node.js

    gcloud builds submit --tag REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql

    Python

    gcloud builds submit --tag REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql

    Java

    此示例使用 Jib 利用常见 Java 工具构建 Docker 映像。无需编写 Dockerfile 或安装 Docker,Jib 便可以优化容器构建。详细了解如何使用 Jib 构建 Java 容器

    1. 使用 gcloud 凭据帮助程序,授权 Docker 推送到您的 Artifact Registry。

      gcloud auth configure-docker

    2. 使用 Jib Maven 插件来构建容器并将其推送到 Artifact Registry。

      mvn compile jib:build -Dimage=REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql

  8. 使用控制台或 CLI 将容器映像部署到 Cloud Run。请注意,部署服务器以允许未经身份验证的访问。 这样,用户就可以加载客户端并开始该过程。服务器会验证手动添加到投票请求的 ID 令牌,对最终用户进行身份验证。

    gcloud run deploy idp-sql \
        --image REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/idp-sql \
        --allow-unauthenticated \
        --service-account idp-sql-identity@PROJECT_ID.iam.gserviceaccount.com \
        --add-cloudsql-instances PROJECT_ID:REGION:CLOUD_SQL_INSTANCE_NAME \
        --update-secrets CLOUD_SQL_CREDENTIALS_SECRET=idp-sql-secrets:latest

    另请注意 --service-account--add-cloudsql-instances--update-secrets 标志,这些标志分别用于指定服务身份、Cloud SQL 实例连接以及版本为环境变量的密钥名称。

收尾工作

Identity Platform 要求您将 Cloud Run 服务网址授权为用户登录后允许的重定向:

  1. 点击身份提供商页面中的笔图标来修改 Google 提供商。

  2. 在右侧面板的“已获授权的网域”下点击添加网域,然后输入 Cloud Run 服务网址。

    您可以在构建或部署后在日志中找到该服务网址,也可以使用以下命令随时获取:

    gcloud run services describe idp-sql --format 'value(status.url)'
  3. 依次转到 API 和服务 > 凭据页面。

    1. 点击 OAuth 客户端 ID 旁边的铅笔图标以进行修改,然后在 Authorized redirect URIs click the添加 URI 按钮。

    2. 在字段中,复制并粘贴以下网址,然后点击页面底部的保存按钮。

    https://PROJECT_ID.firebaseapp.com/__/auth/handler

测试

如需试用完整服务,请执行以下操作:

  1. 在浏览器中导航至上述部署步骤提供的网址。

  2. 点击使用 Google 账号登录按钮,然后完成身份验证流程。

  3. 添加您的投票!

    界面应如下所示:

    界面的屏幕截图显示每个团队的投票计数和投票列表。

如果您选择继续开发这些服务,请注意,它们已限制了 Identity and Access Management (IAM) 对 Google Cloud 其余服务的访问权限,并需要额外的 IAM 角色才能访问众多其他服务。

清理

如果您为本教程创建了一个新项目,请删除项目。 如果您使用的是现有项目,希望保留此项目且不保留本教程中添加的任何更改,请删除为教程创建的资源

删除项目

为了避免产生费用,最简单的方法是删除您为本教程创建的项目。

如需删除项目,请执行以下操作:

  1. In the Google Cloud console, go to the Manage resources page.

    Go to Manage resources

  2. In the project list, select the project that you want to delete, and then click Delete.
  3. In the dialog, type the project ID, and then click Shut down to delete the project.

删除教程资源

  1. 删除您在本教程中部署的 Cloud Run 服务:

    gcloud run services delete SERVICE-NAME

    其中,SERVICE-NAME 是您选择的服务名称。

    您还可以从 Google Cloud 控制台中删除 Cloud Run 服务。

  2. 移除您在教程设置过程中添加的 gcloud 默认区域配置:

     gcloud config unset run/region
    
  3. 移除项目配置:

     gcloud config unset project
    
  4. 删除在本教程中创建的其他 Google Cloud 资源:

后续步骤