将 App Identity 迁移到 OIDC ID 令牌

当在 Python 2 运行时中运行的应用向另一个 App Engine 应用发送请求时,它可以使用 App Engine App Identity API 来声明其身份。接收请求的应用可以使用此身份来确定是否应处理该请求。

如果您的 Python 3 应用在向其他 App Engine 应用发送请求时需要声明其身份,则您可以使用由 Google 的 OAuth 2.0 API 颁发并解码的 OpenID Connect (OIDC) ID 令牌。

下文概述了如何使用 OIDC ID 令牌来声明和验证身份:

  1. 名为“应用 A”的 App Engine 应用从 Google Cloud 运行时环境检索 ID 令牌。
  2. 应用 A 在向应用 B(它是另一个 App Engine 应用)发送请求之前,会先在请求标头中添加此令牌。
  3. 应用 B 使用 Google 的 OAuth 2.0 API 来验证令牌载荷。解码后的载荷包含应用 A 经过验证的身份,其采用应用 A 的默认服务账号的电子邮件地址格式。
  4. 应用 B 将载荷中的身份与允许响应的身份列表进行比较。如果请求来自允许的应用,则应用 B 会处理该请求并做出响应。

OAuth 2.0 流程

本指南介绍了如何更新 App Engine 应用以便使用 OpenID Connect (OIDC) ID 令牌来声明身份,以及如何更新其他 App Engine 应用以便在处理请求之前先使用 ID 令牌来验证身份。

App Identity API 和 OIDC API 之间的主要区别

  • Python 2 运行时版本的应用无需明确声明身份。当应用使用 httpliburlliburllib2 Python 库或 App Engine URL Fetch 服务来发送出站请求时,运行时使用 App Engine URL Fetch 服务发出请求。如果请求正在发送至 appspot.com 网域,则 URL Fetch 会在请求中添加 X-Appengine-Inbound-Appid 标头,以自动声明发出请求的应用的身份。该标头包含应用的 ID(也称为项目 ID)。

    Python 3 运行时版本的应用确实需要明确声明身份,方法是从 Google Cloud 运行时环境检索 OIDC ID 令牌并将其添加到请求标头中。您需要更新向其他 App Engine 应用发送请求的所有代码,以便请求包含 OIDC ID 令牌。

  • 请求中的 X-Appengine-Inbound-Appid 标头包含发送请求的应用的项目 ID。

    Google OIDC ID 令牌的载荷并不会直接标识应用本身的项目 ID。相反,该令牌会提供此服务账号的电子邮件地址,以标识运行该应用的服务账号。您需要添加一些代码,以从令牌载荷中提取用户名。

    如果该服务账号是项目的应用级默认 App Engine 服务账号,则您可以在服务账号的电子邮件地址中找到项目 ID。此地址的用户名部分与项目 ID 相同。在这种情况下,接收方应用代码可在其允许发来请求的项目 ID 列表中查找。

    但是,如果请求方应用使用的是用户管理的服务账号,而不是默认 App Engine 服务账号,则接收方应用可以仅验证该服务账号的身份,这不一定会定义请求方应用的项目 ID。 在这种情况下,接收方应用必须维护一个允许的服务账号电子邮件地址列表,而不是允许的项目 ID 列表。

  • URL Fetch API 调用的配额与用于授予令牌的 Google 的 OAuth 2.0 API 配额不同。您可以在 Google Cloud 控制台 OAuth 同意屏幕中查看您每天可以授予的令牌数量上限。URL Fetch(即 App Identity API)和 Google 的 OAuth 2.0 API 都不会产生费用。

迁移过程概览

如需迁移 Python 应用以使用 OIDC API 声明和验证身份,请执行以下操作:

  1. 在向其他 App Engine 应用发送请求时需要声明身份的应用中:

    1. 等待直至您的应用在 Python 3 环境中运行以迁移到 ID 令牌。

      尽管在 Python 2 运行时中可以使用 ID 令牌,但 Python 2 中的步骤非常复杂,只有在更新应用以在 Python 3 运行时中运行时才暂时需要执行这些步骤。

    2. 当应用在 Python 3 中运行之后,请更新应用以请求 ID 令牌并将该令牌添加到请求标头中。

  2. 在处理请求之前需要验证身份的应用中:

    1. 首先升级 Python 2 应用以便支持 ID 令牌和 App Identity API 身份。这样一来,您的应用就能够验证并处理来自使用 App Identity API 的 Python 2 应用或使用 ID 令牌的 Python 3 应用的请求。

    2. 升级后的 Python 2 应用稳定之后,请将它们迁移至 Python 3 运行时中。继续支持 ID 令牌和 App Identity API 身份,直至确定应用不再需要支持来自旧版应用的请求。

    3. 如果您不再需要处理来自旧版 App Engine 应用的请求,请移除用于验证 App Identity API 身份的代码。

  3. 测试应用之后,请先部署用于处理请求的应用。然后部署更新后的 Python 3 应用,该应用使用 ID 令牌来声明身份。

声明身份

等待直到您的应用在 Python 3 环境中运行,然后按照以下步骤操作来升级应用,以使用 ID 令牌声明身份:

  1. 安装 google-auth 客户端库。

  2. 添加代码以向 Google 的 OAuth 2.0 API 请求 ID 令牌,并在发送请求之前将令牌添加到请求标头中。

  3. 测试您的更新。

为 Python 3 应用安装 google-auth 客户端库

如需将 google-auth 客户端库提供给 Python3 应用,请在 app.yaml 文件所在的文件夹中创建一个 requirements.txt 文件,并添加以下行:

     google-auth

部署应用时,App Engine 将下载 requirements.txt 文件中定义的所有依赖项。

对于本地开发,我们建议您在虚拟环境(例如 venv)中安装依赖项。

添加代码以声明身份

搜索您的代码并查找所有向其他 App Engine 应用发送请求的实例。请先更新上述实例以执行以下操作,然后再发送请求:

  1. 添加以下 import 命令:

    from google.auth.transport import requests as reqs
    from google.oauth2 import id_token
    
  2. 使用 google.oauth2.id_token.fetch_id_token(request, audience) 检索 ID 令牌。在方法调用中添加下列参数:

    • request:传递您准备发送的请求对象。
    • audience:传递您要将请求发送到的应用的网址。这样可以将令牌绑定到请求并防止其他应用使用该令牌。

      为清楚明确起见,我们建议您传递 App Engine 为接收请求的特定服务创建的 appspot.com 网址,即使您对应用使用自定义网域也是如此。

  3. 在请求对象中,设置以下标头:

    'Authorization': 'ID {}'.format(token)
    

例如:

# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from flask import Flask, render_template, request
from google.auth.transport import requests as reqs
from google.oauth2 import id_token
import requests

app = Flask(__name__)


@app.route("/", methods=["GET"])
def index():
    return render_template("index.html")


@app.route("/", methods=["POST"])
def make_request():
    url = request.form["url"]
    token = id_token.fetch_id_token(reqs.Request(), url)

    resp = requests.get(url, headers={"Authorization": f"Bearer {token}"})

    message = f"Response when calling {url}:\n\n"
    message += resp.text

    return message, 200, {"Content-type": "text/plain"}

测试用于声明身份的更新

如需在本地运行您的应用并测试应用是否能够成功发送 ID 令牌,请执行以下操作:

  1. 请按照以下步骤在本地环境中提供默认 App Engine 服务账号的凭据(Google OAuth API 需要这些凭据才能生成 ID 令牌):

    1. 输入以下 gcloud 命令以针对项目的默认 App Engine 账号检索服务账号密钥

      gcloud iam service-accounts keys create ~/key.json --iam-account project-ID@appspot.gserviceaccount.com

      project-ID 替换为您的 Google Cloud 项目的 ID

      现在,服务账号密钥文件已下载到您的机器上。您可以根据需要移动并重命名此文件。务必要安全存储此文件,因为它能够以服务账号的身份进行身份验证。如果丢失了该文件,或者该文件向未授权的用户公开了,请删除服务账号密钥,然后创建一个新密钥

    2. 输入以下命令:

      <code>export GOOGLE_APPLICATION_CREDENTIALS=<var>service-account-key</var></code>
      

    service-account-key 替换为包含所下载服务账号密钥的文件的绝对路径名。

  2. 在您导出 GOOGLE_APPLICATION_CREDENTIALS 环境变量的同一 shell 中,启动 Python 应用

  3. 从应用发送请求并确认操作是否成功。如果您还没有可以接收请求并使用 ID 令牌来验证身份的应用,请执行以下操作:

    1. 下载“传入的”应用示例
    2. 在示例的 main.py 文件中,将您的 Google Cloud 项目 ID 添加到 allowed_app_ids 中。例如:

       allowed_app_ids = [
          '<APP_ID_1>',
          '<APP_ID_2>',
          'my-project-id'
        ]
      
    3. 在 Python 2 本地开发服务器中运行更新后的示例。

验证和处理请求

如需升级 Python 2 应用以便在处理请求之前先使用 ID 令牌或 App Identity API 身份,请执行以下操作:

  1. 安装 google-auth 客户端库。

  2. 更新代码以便执行以下操作:

    1. 如果请求中包含 X-Appengine-Inbound-Appid 标头,请使用该标头验证身份。在旧版运行时(如 Python 2)中运行的应用将包含此标头。

    2. 如果请求不包含 X-Appengine-Inbound-Appid 标头,请检查是否存在 OIDC ID 令牌。如果该令牌存在,请验证令牌载荷并检查发送者的身份

  3. 测试您的更新。

为 Python 2 应用安装 google-auth 客户端库

如需使 Python 2 应用能够使用 google-auth 客户端库,请执行以下操作:

  1. app.yaml 文件所在的文件夹中创建 requirements.txt 文件,并添加以下行:

     google-auth==1.19.2
    

    我们建议您使用 1.19.2 版的 Cloud Logging 客户端库,因为它支持 Python 2.7 应用。

  2. 在应用的 app.yaml 文件的 libraries 部分中,指定 SSL 库(如果尚未指定的话):

    libraries:
    - name: ssl
      version: latest
    
  3. 创建一个用于存储第三方库的目录,如 lib/。然后使用 pip install 将库安装到该目录中。 例如:

    pip install -t lib -r requirements.txt
    
  4. app.yaml 文件所在的文件夹中创建 appengine_config.py 文件。将以下内容添加到 appengine_config.py 文件:

    # appengine_config.py
    import pkg_resources
    from google.appengine.ext import vendor
    
    # Set path to your libraries folder.
    path = 'lib'
    # Add libraries installed in the path folder.
    vendor.add(path)
    # Add libraries to pkg_resources working set to find the distribution.
    pkg_resources.working_set.add_entry(path)
    

    上例中的 appengine_config.py 文件假定 lib 文件夹位于当前工作目录中。如果您无法保证 lib 始终位于当前工作目录中,请指定 lib 文件夹的完整路径。例如:

    import os
    path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'lib')
    

对于本地开发,我们建议您在虚拟环境(如 Python 2 的 virtualenv)中安装依赖项。

更新用于验证请求的代码

搜索您的代码并查找所有获取 X-Appengine-Inbound-Appid 标头值的实例。更新上述实例以执行以下操作:

  1. 添加以下 import 命令:

    from google.auth.transport import requests as reqs
    from google.oauth2 import id_token
    
  2. 如果传入的请求不包含 X-Appengine-Inbound-Appid 标头,请查找 Authorization 标头并检索其值。

    标头值的格式为“ID: token”。

  3. 使用 google.oauth2.id_token.verify_oauth2_token(token, request, audience) 验证并检索解码后的令牌载荷。在方法调用中添加下列参数:

    • token:传递您从传入的请求中提取的令牌。
    • request:传递新的 google.auth.transport.Request 对象。

    • audience:传递当前应用(即正在发送验证请求的应用)的网址。Google 的授权服务器会将此网址与最初生成令牌时提供的网址进行比较。如果网址不匹配,则令牌将无法通过验证,而授权服务器将返回错误。

  4. verify_oauth2_token 方法会返回解码后的令牌载荷,其中包含多个名称/值对,包括生成该令牌的应用的默认服务账号的电子邮件地址。

  5. 从令牌载荷的电子邮件地址中提取用户名。

    用户名与发送请求的应用的项目 ID 相同。此值与之前在 X-Appengine-Inbound-Appid 标头中返回的值相同。

  6. 如果用户名/项目 ID 位于允许的项目 ID 列表中,请处理请求。

例如:

# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Authenticate requests coming from other App Engine instances.
"""

from google.oauth2 import id_token
from google.auth.transport import requests

import logging
import webapp2


def get_app_id(request):
    # Requests from App Engine Standard for Python 2.7 will include a
    # trustworthy X-Appengine-Inbound-Appid. Other requests won't have
    # that header, as the App Engine runtime will strip it out
    incoming_app_id = request.headers.get("X-Appengine-Inbound-Appid", None)
    if incoming_app_id is not None:
        return incoming_app_id

    # Other App Engine apps can get an ID token for the App Engine default
    # service account, which will identify the application ID. They will
    # have to include at token in an Authorization header to be recognized
    # by this method.
    auth_header = request.headers.get("Authorization", None)
    if auth_header is None:
        return None

    # The auth_header must be in the form Authorization: Bearer token.
    bearer, token = auth_header.split()
    if bearer.lower() != "bearer":
        return None

    try:
        info = id_token.verify_oauth2_token(token, requests.Request())
        service_account_email = info["email"]
        incoming_app_id, domain = service_account_email.split("@")
        if domain != "appspot.gserviceaccount.com":  # Not App Engine svc acct
            return None
        else:
            return incoming_app_id
    except Exception as e:
        # report or log if desired, as here:
        logging.warning("Request has bad OAuth2 id token: {}".format(e))
        return None


class MainPage(webapp2.RequestHandler):
    allowed_app_ids = ["other-app-id", "other-app-id-2"]

    def get(self):
        incoming_app_id = get_app_id(self.request)

        if incoming_app_id is None:
            self.abort(403)

        if incoming_app_id not in self.allowed_app_ids:
            self.abort(403)

        self.response.write("This is a protected page.")


app = webapp2.WSGIApplication([("/", MainPage)], debug=True)

测试用于验证身份的更新

如需测试应用是否能够使用 ID 令牌或 X-Appengine-Inbound-Appid 标头来验证请求,请在 Python 2 本地开发服务器中运行应用,然后从 Python 2 应用(它将使用 App Identity API)和发送 ID 令牌的 Python 3 应用发送请求。

如果您尚未更新应用以发送 ID 令牌,请执行以下操作:

  1. 下载“发出请求的”应用示例

  2. 按照测试声明应用的更新中的说明,将服务账号凭据添加到本地环境。

  3. 使用标准 Python 3 命令启动 Python 3 示例应用

  4. 从示例应用发送请求并确认操作是否成功。

部署应用

准备好部署应用后,您应该执行以下操作:

  1. 在 App Engine 上测试应用

  2. 如果应用可无错误正常运行,请使用流量拆分功能为更新后的应用缓慢增加流量。请先仔细监控应用是否存在任何问题,若无再将更多流量路由到更新后的应用。

使用其他服务账号声明身份

当您请求 ID 令牌时,请求默认使用 App Engine 默认服务账号的身份。在验证令牌时,令牌载荷包含默认服务账号的电子邮件地址,该地址映射到您的应用的项目 ID。

App Engine 默认服务账号默认具有较高的权限级别。它可以查看和修改整个 Google Cloud 项目,因此在大多数情况下,如果您的应用需要通过 Cloud 服务进行身份验证,则不适合使用此账号。

但是,在声明应用身份时,可以安全地使用默认服务账号,因为您只使用 ID 令牌来验证发送请求的应用的身份。在此过程中,不考虑或不需要已向服务账号授予的实际权限

如果您仍希望使用其他服务账号来处理 ID 令牌请求,请执行以下操作:

  1. 将名为 GOOGLE_APPLICATION_CREDENTIALS 的环境变量设置为包含服务账号凭据的 JSON 文件的路径。请参阅我们提供的建议以了解如何安全存储这些凭据

  2. 使用 google.oauth2.id_token.fetch_id_token(request, audience) 检索 ID 令牌。

  3. 验证此令牌时,令牌载荷将包含新服务账号的电子邮件地址。