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

本教程介绍如何在 Cloud Run 上创建 Web 应用并将访问权限限制为已登录用户和存储在 PostgreSQL 中的数据。Identity Platform 集成处理最终用户身份验证,并提供用户 ID 令牌以授权服务查询 Cloud SQL 数据库。此服务有一个公开客户端,可让外部用户登录并访问投票界面进行投票。

为简单起见,本教程使用 Google 作为提供商:用户必须使用 Google 帐号登录。但是,您可以使用其他提供商或身份验证方法来登录用户

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

在本示例中,最终用户身份验证流程包括:

  1. 客户端根据 Identity Platform 请求用户的签名 ID 令牌。
  2. 签名 ID 令牌随请求一起发送到服务器。
  3. 服务器使用该签名 ID 令牌验证用户身份,并授予对 Cloud SQL PostgreSQL 数据库的访问权限。

本教程未展示以下身份验证方法的用法:

  • IAM 身份验证,使用 IAM 角色自动声明和验证身份。建议用于服务到服务身份验证,而不是单个身份。如需详细了解服务到服务身份验证,请参阅教程:保护 Cloud Run 服务安全目前,无法组合使用基于 IAM 的身份验证和 ID 令牌方法。

目标

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

    • 使用 Identity Platform 对最终用户进行身份验证以访问 Cloud Run 服务。

    • 使用 Secret Manager 处理敏感数据,从而将 Cloud Run 服务关联到 PostgreSQL 数据库。

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

费用

本教程使用 Google Cloud 的以下收费组件:

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

您应该能够完全使用免费试用赠金完成此项目。

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

准备工作

  1. 登录您的 Google Cloud 帐号。如果您是 Google Cloud 新手,请创建一个帐号来评估我们的产品在实际场景中的表现。新客户还可获享 $300 赠金,用于运行、测试和部署工作负载。
  2. 在 Google Cloud Console 的项目选择器页面上,选择或创建一个 Google Cloud 项目。

    转到“项目选择器”

  3. 确保您的 Cloud 项目已启用结算功能。 了解如何确认您的项目是否已启用结算功能

  4. 在 Google Cloud Console 的项目选择器页面上,选择或创建一个 Google Cloud 项目。

    转到“项目选择器”

  5. 确保您的 Cloud 项目已启用结算功能。 了解如何确认您的项目是否已启用结算功能

  6. 启用 Cloud Run, Secret Manager, Cloud SQL, Container Registry, and Cloud Build API。

    启用 API

  7. 安装并初始化 Cloud SDK。

设置 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 价格

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

如果您已创建 Cloud Run 服务,则可以在 Cloud Console 的 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/

直观呈现架构

架构图
上图展示了用户通过 IdP 提供的 Google 登录弹出式窗口登录,然后与用户的身份一起被重定向回 Cloud Run。
  1. 用户向服务发出第一个请求。

  2. Cloud Run 服务为客户端提供用户登录表单。

  3. 用户通过使用 Identity Platform 的 Google 登录弹出式窗口登录。

  4. 身份验证流程将用户与用户的身份一起重定向回 Cloud Run 服务。

  5. 当用户投票时,客户端创建一个 ID 令牌并将其附加到服务器请求。服务器验证该 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 与 Identity Platform 集成,并通过从客户端发送的用户 ID 令牌验证用户的身份。如果提供的 ID 令牌格式正确、未过期并且经过正确签名,则该方法会返回提取该用户的 Identity Platform uid 所需的已解码的 ID 令牌。

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]:
    @wraps(func)
    def decorated_function(*args: Any, **kwargs: Any) -> Any:
        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, str]
) -> sqlalchemy.engine.base.Engine:
    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": "{}/{}/.s.PGSQL.5432".format(
                    db_socket_dir, cloud_sql_connection_name  # e.g. "/cloudsql"
                )  # i.e "<PROJECT-NAME>:<INSTANCE-REGION>:<INSTANCE-NAME>"
            },
        ),
        **db_config,
    )
    pool.dialect.description_encoding = None
    logger.info("Database engine initialised from unix conection")

    return pool

Java

借助 Spring Cloud Google Cloud PostgreSQL 入门版集成,您可以使用 Spring JDBC 库与 Google 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]:
    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 设置

您需要在 Cloud Console 中手动设置 Identity Platform。

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

    转到 Identity Platform Marketplace 页面

  2. 点击启用 Identity Platform。这将创建一个名为 Web client (auto created by Google Service) 的 OAuth2 客户端 ID。

  3. 下载生成的 OAuth2 ID:

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

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

    2. 点击“Web client (auto created by Google Service)”记录的下载图标。

    3. 在下载的 JSON 输出中,记下 client_idclient_secret

  4. 将 Google 配置为提供商:

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

      转到“身份提供商”页面

    2. 点击添加提供商

    3. 从列表中选择 Google

    4. 在“Web SDK 配置”设置中,输入您之前下载的 JSON 中的值:

      1. Web 客户端 ID:client_id

      2. 网页客户端密钥:client_secret

    5. 点击配置屏幕

      1. 对于用户类型,选择外部

      2. 填写必填字段(支持电子邮件、开发者电子邮件)。

      3. 继续操作,直至到达摘要页面。

    6. 在“配置您的应用”下,点击设置详情。将代码段复制到示例的 static/config.js 中以初始化 Identity Platform Client 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. 允许服务帐号访问创建的密钥:

      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. 使用 Cloud Build 构建容器映像:

    Node.js

    gcloud builds submit --tag gcr.io/PROJECT_ID/idp-sql

    Python

    gcloud builds submit --tag gcr.io/PROJECT_ID/idp-sql

    Java

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

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

      gcloud auth configure-docker

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

      mvn compile jib:build -Dimage=gcr.io/PROJECT_ID/idp-sql

  7. 使用控制台或 CLI 将容器映像部署到 Cloud Run:

    gcloud beta run deploy idp-sql \
        --image gcr.io/PROJECT_ID/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 实例连接以及版本密钥名称分别指定为环境变量。

收尾工作

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

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

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

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

    gcloud run services describe idp-sql --format 'value(status.url)'

试用

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

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

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

  3. 添加您的投票!

    界面应如下所示:

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

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

清除数据

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

删除项目

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

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

  1. 在 Cloud Console 中,转到管理资源页面。

    转到“管理资源”

  2. 在项目列表中,选择要删除的项目,然后点击删除
  3. 在对话框中输入项目 ID,然后点击关闭以删除项目。

删除教程资源

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

    gcloud run services delete SERVICE-NAME

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

    您还可以从 Google Cloud Console 中删除 Cloud Run 服务。

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

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

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

后续步骤