使用跨帐号保护功能保护用户帐号

如果您的应用允许用户使用 Google 帐号登录,您可以监听并响应跨帐号保护服务提供的安全事件通知,从而提高这些共享用户帐号的安全性。

这些通知旨在提醒您注意用户的 Google 帐号发生的重大变化,这些变化通常也会影响其用户帐号使用您应用的安全保护。例如,如果用户的 Google 帐号被盗用,则可能会通过电子邮件帐号恢复或使用单点登录导致用户帐号遭到盗用。

为了帮助您降低此类事件的风险,Google 会向您发送服务对象(称为安全事件令牌)。这些令牌提供的信息很少(只有安全事件的类型和发生时间以及受影响用户的标识符),但您可以使用它们采取相应措施。例如,如果用户的 Google 帐号遭到入侵,您可以暂时停用该用户的“使用 Google 帐号登录”功能,并禁止将帐号恢复电子邮件发送到用户的 Gmail 地址。

跨帐号保护功能基于 OpenID Foundation 制定的 RISC 标准

概览

如需将跨帐号保护功能用于您的应用或服务,您必须完成以下任务:

  1. 在 API Console中设置您的项目。

  2. 创建事件接收器端点,Google 会将端点发送到该端点。此端点负责验证其收到的令牌,然后以您选择的任何方式响应安全事件。

  3. 向 Google 注册您的端点,以开始接收安全事件令牌。

先决条件

您只会收到已授予服务访问其个人资料信息或电子邮件地址的 Google 用户的安全事件令牌。您可以通过请求 profileemail 范围来获得此权限。默认情况下,较新的 Google 登录 SDK 或旧版 Google 登录 SDK 会请求这些范围,但如果您不使用默认设置,或者直接访问 Google 的 OpenID Connect 端点,请确保您至少请求其中一个范围。

在 API Console中设置项目

您必须先创建服务帐号并在API Console 项目中启用 RISC API,然后才能开始接收安全性事件令牌。您必须在应用中使用用于访问 Google 服务(例如 Google 登录)的API Console 项目。

如需创建服务帐号,请执行以下操作:

  1. 打开 API Console Credentials page。出现提示时,请选择您在应用中访问 Google 服务时所用的API Console项目。

  2. 点击创建凭据 > 服务帐号

  3. 按照这些说明创建具有 RISC 配置管理员角色 (roles/riscconfigs.admin) 的新服务帐号。

  4. 为新创建的服务帐号创建密钥。选择 JSON 密钥类型,然后点击创建。创建密钥后,您将下载包含服务帐号凭据的 JSON 文件。请将此文件保存在安全的地方,但也可通过事件接收器端点访问。

在项目的“凭据”页面上,还请注意用于使用 Google 帐号登录或 Google 登录(旧版)的客户端 ID。通常,您支持的每个平台都有一个客户端 ID。您需要使用这些客户端 ID 来验证安全事件令牌,如下一部分中所述。

如需启用 RISC API,请执行以下操作:

  1. 打开API Console中的 RISC API 页面。确保用于访问 Google 服务的项目仍处于选中状态。

  2. 阅读 RISC 条款并确保您了解相关要求。

    如果要为组织拥有的项目启用 API,请确保您有权将您的组织绑定到 RISC 条款。

  3. 除非您同意 RISC 条款,否则请点击启用

创建事件接收器端点

要从 Google 接收安全事件通知,您需要创建一个 HTTPS 端点来处理 HTTPS POST 请求。注册此端点(见下文)后,Google 将开始向端点发布经过加密签名的字符串(称为安全事件令牌)。安全事件令牌是包含单个安全相关事件信息的已签名 JWT。

对于您在端点接收的每个安全事件令牌,请先验证并解码令牌,然后根据您的服务酌情处理安全事件。在解码之前必须验证事件令牌,以防止不法分子进行恶意攻击。以下部分介绍了这些任务:

1. 解码并验证安全性事件令牌

由于安全事件令牌是特定类型的 JWT,因此您可以使用任何 JWT 库(例如 jwt.io 上列出的库)来解码和验证它们。无论您使用哪种库,令牌验证码都必须执行以下操作:

  1. 从 Google 的 RISC 配置文档(位于 https://accounts.google.com/.well-known/risc-configuration 中)获取跨帐号保护服务发行者标识符 (issuer) 和签名密钥 URI (jwks_uri)。
  2. 使用您选择的 JWT 库,从安全事件令牌的标头中获取签名密钥 ID。
  3. 从 Google 的签名密钥证书文档中,获取您在上一步中获取的密钥 ID 对应的公钥。如果文档不包含具有您要查找的 ID 的密钥,则安全事件令牌可能无效,您的端点应返回 HTTP 错误 400。
  4. 使用您选择的 JWT 库验证以下内容:
    • 安全事件令牌使用您在上一步中获取的公钥进行签名。
    • 令牌的 aud 声明是应用的某个客户端 ID。
    • 令牌的 iss 声明与从 RISC 发现文档获得的发卡机构标识符匹配。请注意,您无需验证令牌的过期时间 (exp),因为安全事件令牌代表历史事件,因此不会过期。

例如:

Java

使用 java-jwtjwks-rsa-java

public DecodedJWT validateSecurityEventToken(String token) {
    DecodedJWT jwt = null;
    try {
        // In a real implementation, get these values from
        // https://accounts.google.com/.well-known/risc-configuration
        String issuer = "accounts.google.com";
        String jwksUri = "https://www.googleapis.com/oauth2/v3/certs";

        // Get the ID of the key used to sign the token.
        DecodedJWT unverifiedJwt = JWT.decode(token);
        String keyId = unverifiedJwt.getKeyId();

        // Get the public key from Google.
        JwkProvider googleCerts = new UrlJwkProvider(new URL(jwksUri), null, null);
        PublicKey publicKey = googleCerts.get(keyId).getPublicKey();

        // Verify and decode the token.
        Algorithm rsa = Algorithm.RSA256((RSAPublicKey) publicKey, null);
        JWTVerifier verifier = JWT.require(rsa)
                .withIssuer(issuer)
                // Get your apps' client IDs from the API console:
                // https://console.developers.google.com/apis/credentials?project=_
                .withAudience("123456789-abcedfgh.apps.googleusercontent.com",
                              "123456789-ijklmnop.apps.googleusercontent.com",
                              "123456789-qrstuvwx.apps.googleusercontent.com")
                .acceptLeeway(Long.MAX_VALUE)  // Don't check for expiration.
                .build();
        jwt = verifier.verify(token);
    } catch (JwkException e) {
        // Key not found. Return HTTP 400.
    } catch (InvalidClaimException e) {

    } catch (JWTDecodeException exception) {
        // Malformed token. Return HTTP 400.
    } catch (MalformedURLException e) {
        // Invalid JWKS URI.
    }
    return jwt;
}

Python

import json
import jwt       # pip install pyjwt
import requests  # pip install requests

def validate_security_token(token, client_ids):
    # Get Google's RISC configuration.
    risc_config_uri = 'https://accounts.google.com/.well-known/risc-configuration'
    risc_config = requests.get(risc_config_uri).json()

    # Get the public key used to sign the token.
    google_certs = requests.get(risc_config['jwks_uri']).json()
    jwt_header = jwt.get_unverified_header(token)
    key_id = jwt_header['kid']
    public_key = None
    for key in google_certs['keys']:
        if key['kid'] == key_id:
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
    if not public_key:
        raise Exception('Public key certificate not found.')
        # In this situation, return HTTP 400

    # Decode the token, validating its signature, audience, and issuer.
    try:
        token_data = jwt.decode(token, public_key, algorithms='RS256',
                                options={'verify_exp': False},
                                audience=client_ids, issuer=risc_config['issuer'])
    except:
        raise
        # Validation failed. Return HTTP 400.
    return token_data

# Get your apps' client IDs from the API console:
# https://console.developers.google.com/apis/credentials?project=_
client_ids = ['123456789-abcedfgh.apps.googleusercontent.com',
              '123456789-ijklmnop.apps.googleusercontent.com',
              '123456789-qrstuvwx.apps.googleusercontent.com']
token_data = validate_security_token(token, client_ids)

如果令牌有效且已成功解码,则返回 HTTP 状态 202。 然后,处理令牌指示的安全事件。

2. 处理安全事件

解码后,安全事件令牌类似于以下示例:

{
  "iss": "https://accounts.google.com/",
  "aud": "123456789-abcedfgh.apps.googleusercontent.com",
  "iat": 1508184845,
  "jti": "756E69717565206964656E746966696572",
  "events": {
    "https://schemas.openid.net/secevent/risc/event-type/account-disabled": {
      "subject": {
        "subject_type": "iss-sub",
        "iss": "https://accounts.google.com/",
        "sub": "7375626A656374"
      },
      "reason": "hijacking"
    }
  }
}

issaud 声明指示令牌的颁发者 (Google) 和令牌的预期收件人(您的服务)。您在上一步中验证了这些声明。

jti 声明是一个字符串,用于标识单个安全性事件,是数据流独有的。您可以使用此标识符来跟踪您收到了哪些安全事件。

events 声明包含令牌表示的安全事件的相关信息。此声明是从事件类型标识符到 subject 声明的映射,后者指定该事件涉及的用户,以及关于可能发生的事件的任何其他详细信息。

subject 声明使用用户的唯一 Google 帐号 ID (sub) 来标识特定用户。此 Google 帐号 ID 由较新的登录 Google(JavaScriptHTML)库、旧版 Google 登录库或 OpenID Connect 颁发的 JWT ID 令牌中包含的同一标识符 (sub)。当声明的 subject_typeid_token_claims 时,它可能还包含带有用户的电子邮件地址的 email 字段。

使用 events 声明中的信息,针对指定用户帐号的事件类型采取相应措施。

OAuth 令牌标识符

对于各个令牌的 OAuth 事件,令牌主题标识符类型包含以下字段:

  • token_type:仅支持 refresh_token

  • token_identifier_alg:请参阅下表了解可能的值。

  • token:请参阅下表。

token_identifier_alg 令牌
prefix 令牌的前 16 个字符。
hash_base64_sha512_sha512 令牌的双哈希(使用 SHA-512)。

如果与这些事件集成,建议根据这些可能的值将令牌编入索引,以确保在收到事件时快速匹配。

支持的事件类型

跨帐号保护功能支持以下类型的安全事件:

事件类型 属性 如何回复
https://schemas.openid.net/secevent/risc/event-type/sessions-revoked 必需:通过结束用户当前打开的会话来重新保护用户帐号。
https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked

必需:如果令牌用于 Google 登录,请终止用户当前打开的会话。此外,您还可以建议用户设置备用登录方法。

建议:如果令牌是用于访问其他 Google API 的,请删除您用户存储的任何 OAuth 令牌。

https://schemas.openid.net/secevent/oauth/event-type/token-revoked 如需了解令牌标识符,请参阅 OAuth 令牌标识符部分

必需:如果您存储了相应的刷新令牌,请删除它,并在下次需要访问令牌时请求用户重新同意。

https://schemas.openid.net/secevent/risc/event-type/account-disabled reason=hijacking
reason=bulk-account

必需:如果帐号停用的原因为 hijacking,请结束当前打开的会话,重新保护用户帐号的安全。

建议:如果帐号停用的原因是 bulk-account,请分析用户在您的服务中的活动,并确定适当的后续操作。

建议:如果未提供原因,请为用户停用 Google 登录功能,并使用与用户的 Google 帐号关联的电子邮件地址(通常但不一定是 Gmail 帐号)停用帐号恢复功能。为用户提供另一种登录方法。

https://schemas.openid.net/secevent/risc/event-type/account-enabled 建议:为用户重新启用 Google 登录,并使用用户的 Google 帐号电子邮件地址重新启用帐号恢复功能。
https://schemas.openid.net/secevent/risc/event-type/account-purged 建议:删除用户的帐号或为其提供备用登录方法。
https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required 建议:注意您的服务是否存在可疑活动,并采取相应措施。
https://schemas.openid.net/secevent/risc/event-type/verification 状态 = state 建议:记录测试令牌已收到。

重复和错过的活动

跨帐号保护功能将尝试重新传送它认为未传送的事件。因此,您可能会多次收到同一事件。如果这样做会重复执行操作并给用户带来不便,不妨考虑使用 jti 声明(相应事件的唯一标识符)移除相应事件。Google Cloud Dataflow 等外部工具可帮助您执行去重数据流。

请注意,由于传送的重试次数有限,因此如果您的接收器在很长一段时间内处于离线状态,您可能会永久错过某些事件。

注册接收器

如需开始接收安全事件,请使用 RISC API 注册接收器端点。对 RISC API 的调用必须附有授权令牌。

您只会收到面向应用用户的安全事件,因此需要在 GCP 项目中配置 OAuth 权限请求页面,以执行以下步骤。

1. 生成授权令牌

如需为 RISC API 生成授权令牌,请使用以下声明创建 JWT:

{
  "iss": SERVICE_ACCOUNT_EMAIL,
  "sub": SERVICE_ACCOUNT_EMAIL,
  "aud": "https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService",
  "iat": CURRENT_TIME,
  "exp": CURRENT_TIME + 3600
}

使用服务帐号的私钥为 JWT 签名,您可以在创建服务帐号密钥时下载的 JSON 文件中找到私钥。

例如:

Java

使用 java-jwtGoogle 的身份验证库

public static String makeBearerToken() {
    String token = null;
    try {
        // Get signing key and client email address.
        FileInputStream is = new FileInputStream("your-service-account-credentials.json");
        ServiceAccountCredentials credentials =
               (ServiceAccountCredentials) GoogleCredentials.fromStream(is);
        PrivateKey privateKey = credentials.getPrivateKey();
        String keyId = credentials.getPrivateKeyId();
        String clientEmail = credentials.getClientEmail();

        // Token must expire in exactly one hour.
        Date issuedAt = new Date();
        Date expiresAt = new Date(issuedAt.getTime() + 3600000);

        // Create signed token.
        Algorithm rsaKey = Algorithm.RSA256(null, (RSAPrivateKey) privateKey);
        token = JWT.create()
                .withIssuer(clientEmail)
                .withSubject(clientEmail)
                .withAudience("https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService")
                .withIssuedAt(issuedAt)
                .withExpiresAt(expiresAt)
                .withKeyId(keyId)
                .sign(rsaKey);
    } catch (ClassCastException e) {
        // Credentials file doesn't contain a service account key.
    } catch (IOException e) {
        // Credentials file couldn't be loaded.
    }
    return token;
}

Python

import json
import time

import jwt  # pip install pyjwt

def make_bearer_token(credentials_file):
    with open(credentials_file) as service_json:
        service_account = json.load(service_json)
        issuer = service_account['client_email']
        subject = service_account['client_email']
        private_key_id = service_account['private_key_id']
        private_key = service_account['private_key']
    issued_at = int(time.time())
    expires_at = issued_at + 3600
    payload = {'iss': issuer,
               'sub': subject,
               'aud': 'https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService',
               'iat': issued_at,
               'exp': expires_at}
    encoded = jwt.encode(payload, private_key, algorithm='RS256',
                         headers={'kid': private_key_id})
    return encoded

auth_token = make_bearer_token('your-service-account-credentials.json')

此授权令牌可用于进行 RISC API 调用一小时。令牌过期后,请生成新的令牌,以便继续进行 RISC API 调用。

2. 调用 RISC 流配置 API

现在您已经有了授权令牌,接下来可以使用 RISC API 来配置项目的安全事件流,包括注册接收器端点。

为此,请向 https://risc.googleapis.com/v1beta/stream:update 发出 HTTPS POST 请求,并指定接收器端点和您感兴趣的安全事件类型

POST /v1beta/stream:update HTTP/1.1
Host: risc.googleapis.com
Authorization: Bearer AUTH_TOKEN

{
  "delivery": {
    "delivery_method":
      "https://schemas.openid.net/secevent/risc/delivery-method/push",
    "url": RECEIVER_ENDPOINT
  },
  "events_requested": [
    SECURITY_EVENT_TYPES
  ]
}

例如:

Java

public static void configureEventStream(final String receiverEndpoint,
                                        final List<String> eventsRequested,
                                        String authToken) throws IOException {
    ObjectMapper jsonMapper = new ObjectMapper();
    String streamConfig = jsonMapper.writeValueAsString(new Object() {
        public Object delivery = new Object() {
            public String delivery_method =
                    "https://schemas.openid.net/secevent/risc/delivery-method/push";
            public String url = receiverEndpoint;
        };
        public List<String> events_requested = eventsRequested;
    });

    HttpPost updateRequest = new HttpPost("https://risc.googleapis.com/v1beta/stream:update");
    updateRequest.addHeader("Content-Type", "application/json");
    updateRequest.addHeader("Authorization", "Bearer " + authToken);
    updateRequest.setEntity(new StringEntity(streamConfig));

    HttpResponse updateResponse = new DefaultHttpClient().execute(updateRequest);
    Header[] responseContentTypeHeaders = updateResponse.getHeaders("Content-Type");
    StatusLine responseStatus = updateResponse.getStatusLine();
    int statusCode = responseStatus.getStatusCode();
    HttpEntity entity = updateResponse.getEntity();
    // Now handle response
}

// ...

configureEventStream(
        "https://your-service.example.com/security-event-receiver",
        Arrays.asList(
                "https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required",
                "https://schemas.openid.net/secevent/risc/event-type/account-disabled"),
        authToken);

Python

import requests

def configure_event_stream(auth_token, receiver_endpoint, events_requested):
    stream_update_endpoint = 'https://risc.googleapis.com/v1beta/stream:update'
    headers = {'Authorization': 'Bearer {}'.format(auth_token)}
    stream_cfg = {'delivery': {'delivery_method': 'https://schemas.openid.net/secevent/risc/delivery-method/push',
                               'url': receiver_endpoint},
                  'events_requested': events_requested}
    response = requests.post(stream_update_endpoint, json=stream_cfg, headers=headers)
    response.raise_for_status()  # Raise exception for unsuccessful requests

configure_event_stream(auth_token, 'https://your-service.example.com/security-event-receiver',
                       ['https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required',
                        'https://schemas.openid.net/secevent/risc/event-type/account-disabled'])

如果请求返回 HTTP 200,则表示事件流已成功配置,您的接收器端点应开始接收安全事件令牌。下一部分介绍如何测试流配置和端点,以验证一切是否正常运行。

获取并更新当前的数据流配置

将来,如果您需要修改数据流配置,可以通过向 https://risc.googleapis.com/v1beta/stream 发出经过授权的 GET 请求来获取当前数据流配置,修改响应正文,然后按照上述说明将修改后的配置发布回 https://risc.googleapis.com/v1beta/stream:update

停止和恢复活动流

如果您需要停止来自 Google 的事件流,请在请求正文中使用 { "status": "disabled" }https://risc.googleapis.com/v1beta/stream/status:update 发出已授权的 POST 请求。数据流停用时,Google 不会向端点发送事件,也不会在发生安全事件时进行缓冲。如需重新启用事件流,请将 { "status": "enabled" } 发布到同一端点。

3. 可选:测试流配置

您可以通过事件流发送验证令牌,以验证数据流配置和接收器端点能否正常工作。此令牌可以包含唯一字符串,您可以使用该字符串来验证是否在端点收到了令牌。

如需请求验证令牌,请向 https://risc.googleapis.com/v1beta/stream:verify 发出经过授权的 HTTPS POST 请求。在请求正文中,指定一些标识性字符串:

{
  "state": "ANYTHING"
}

例如:

Java

public static void testEventStream(final String stateString,
                                   String authToken) throws IOException {
    ObjectMapper jsonMapper = new ObjectMapper();
    String json = jsonMapper.writeValueAsString(new Object() {
        public String state = stateString;
    });

    HttpPost updateRequest = new HttpPost("https://risc.googleapis.com/v1beta/stream:verify");
    updateRequest.addHeader("Content-Type", "application/json");
    updateRequest.addHeader("Authorization", "Bearer " + authToken);
    updateRequest.setEntity(new StringEntity(json));

    HttpResponse updateResponse = new DefaultHttpClient().execute(updateRequest);
    Header[] responseContentTypeHeaders = updateResponse.getHeaders("Content-Type");
    StatusLine responseStatus = updateResponse.getStatusLine();
    int statusCode = responseStatus.getStatusCode();
    HttpEntity entity = updateResponse.getEntity();
    // Now handle response
}

// ...

testEventStream("Test token requested at " + new Date().toString(), authToken);

Python

import requests
import time

def test_event_stream(auth_token, nonce):
    stream_verify_endpoint = 'https://risc.googleapis.com/v1beta/stream:verify'
    headers = {'Authorization': 'Bearer {}'.format(auth_token)}
    state = {'state': nonce}
    response = requests.post(stream_verify_endpoint, json=state, headers=headers)
    response.raise_for_status()  # Raise exception for unsuccessful requests

test_event_stream(auth_token, 'Test token requested at {}'.format(time.ctime()))

如果请求成功,系统会将验证令牌发送到您注册的端点。例如,如果您的端点通过简单地记录令牌来处理令牌,则可以检查日志以确认是否收到了令牌。

错误代码参考

RISC API 可能会返回以下错误:

错误代码 错误消息 建议采取的措施
400 数据流配置必须包含 $fieldname 字段。 您对 https://risc.googleapis.com/v1beta/stream:update 端点的请求无效或无法解析。请在您的请求中添加 $fieldname
401 未获授权。 授权失败。请确保在请求中附上了授权令牌,且该令牌有效且未过期。
403 递送端点必须是 HTTPS 网址。 您的传送端点(即您希望用于接收 RISC 事件的端点)必须是 HTTPS。我们不会向 HTTP 网址发送 RISC 事件。
403 现有流配置没有适用于 RISC 的合规传送方法。 您的 Google Cloud 项目必须已有 RISC 配置。如果您使用的是 Firebase 并且启用了 Google 登录,则 Firebase 将为您的项目管理 RISC;您将无法创建自定义配置。如果您没有为您的 Firebase 项目使用 Google 登录,请停用该项目,然后一个小时后再尝试更新。
403 找不到项目。 确保您为正确的项目使用了正确的服务帐号。您使用的服务帐号可能与已删除的项目相关联。了解如何查看与项目关联的所有服务帐号
403 服务帐号需要具备访问您的 RISC 配置的权限 转到项目的 API Console ,然后按照这些说明为调用项目的服务帐号分配“RISC Configuration Admin”角色 (roles/riscconfigs.admin)。
403 流管理 API 只能由服务帐号调用。 如需了解详情,请参阅如何使用服务帐号调用 Google API
403 传送端点不属于您项目的任何网域。 每个项目都有一组已获授权的网域。如果您的传送端点(即您希望用于传送 RISC 事件的端点)未托管在其中某一端点上,您需要将该端点的网域添加到该端点中。
403 要使用此 API,您的项目必须配置至少一个 OAuth 客户端。 RISC 仅在构建支持 Google 登录的应用时才有效。 此连接需要使用 OAuth 客户端。如果您的项目中没有 OAuth 客户端,RISC 可能对您没有任何用处。详细了解 Google 在 API 方面的 OAuth 使用情况
403

状态不受支持。

状态无效。

目前,我们仅支持数据流状态“enabled”和“disabled”。
404

项目没有 RISC 配置。

项目尚无 RISC 配置,因此无法更新状态。

调用 https://risc.googleapis.com/v1beta/stream:update 端点以创建新的数据流配置。
4XX/5XX 无法更新状态。 如需了解详情,请查看详细的错误消息。

访问令牌范围

如果您决定使用访问令牌对 RISC API 进行身份验证,应用必须请求以下范围:

端点 作用域
https://risc.googleapis.com/v1beta/stream/status https://www.googleapis.com/auth/risc.status.readonlyhttps://www.googleapis.com/auth/risc.status.readwrite
https://risc.googleapis.com/v1beta/stream/status:update https://www.googleapis.com/auth/risc.status.readwrite
https://risc.googleapis.com/v1beta/stream https://www.googleapis.com/auth/risc.configuration.readonlyhttps://www.googleapis.com/auth/risc.configuration.readwrite
https://risc.googleapis.com/v1beta/stream:update https://www.googleapis.com/auth/risc.configuration.readwrite
https://risc.googleapis.com/v1beta/stream:verify https://www.googleapis.com/auth/risc.verify

需要帮助?

首先,请查看我们的错误代码参考部分。如果您仍有疑问,请在 Stack Overflow 上发布带有 #SecEvents 标记的问题。