使用签名 Cookie

本页面简要介绍签名 Cookie 以及将其用于 Cloud CDN 的说明。无论用户是否拥有 Google 账号,都可以通过签名 Cookie 获得对一组文件的限时资源访问权限。

签名 Cookie 是签名网址的替代方案。如果在应用中为每个用户单独签名数十乃至数百个网址不可行时,签名 Cookie 可以保障访问的安全。

通过签名 Cookie,您可以实现以下目标:

  • 向用户授权,并向他们提供用于访问受保护内容的限时令牌(而不必对每个网址进行签名)。
  • 将用户的访问权限限制在特定网址前缀(如 https://media.example.com/videos/)的范围内,并仅向该授权用户授予对该网址前缀内受保护内容的访问权限。
  • 保持您的网址和媒体清单不变,从而简化打包流水线并提高可缓存性。

如果您希望将访问权限限制在特定的网址范围内,请考虑使用签名网址

准备工作

在使用签名 Cookie 之前,请执行以下操作:

  • 确保已启用 Cloud CDN;如需了解相关说明,请参阅使用 Cloud CDN。您可以先在后端配置签名 Cookie,然后再启用 Cloud CDN;在启用 Cloud CDN 之前,相关配置不会生效。

  • 如有必要,请更新到 Google Cloud CLI 的最新版本:

    gcloud components update
    

如需查看概览,请参阅签名网址和签名 Cookie

配置签名请求密钥

为签名网址或签名 Cookie 创建密钥需要执行几个步骤,详情请参阅下面几部分内容。

安全注意事项

在下列情况下,Cloud CDN 不会验证请求:

  • 请求未签名。
  • 请求的后端服务或后端存储分区未启用 Cloud CDN。

系统必须始终先在源站验证签名的请求,然后再传送响应。这是因为源站可用于传送签名的和未签名的混合内容,而且客户端可能会直接访问源站。

  • Cloud CDN 不会屏蔽没有 Signature 查询参数或 Cloud-CDN-Cookie HTTP Cookie 的请求。它会拒绝请求参数无效(或格式不正确)的请求。
  • 当您的应用检测到无效签名时,请确保其使用 HTTP 403 (Unauthorized) 响应代码来作出响应。HTTP 403 响应代码不可缓存。
  • 签名请求和未签名请求的响应会单独缓存,因此对有效签名请求的成功响应绝不会用于传送未签名的请求。
  • 如果您的应用向无效请求发送了可缓存的响应代码,则未来的有效请求可能会被错误地拒绝。

对于 Cloud Storage 后端,请务必移除公共访问权限,以便 Cloud Storage 可以拒绝缺少有效签名的请求。

下表总结了这种行为。

请求具有签名 缓存命中 行为
转发到后端源站。
从缓存传送。
验证签名。如果有效,则转发到后端源站。
验证签名。如果有效,则从缓存传送。

创建签名请求密钥

如需启用对 Cloud CDN 签名网址和签名 Cookie 的支持,您可以对启用了 Cloud CDN 的后端服务和/或后端存储分区创建一个或多个密钥。

您可以根据自己的安全需求,为每个后端服务或后端存储分区创建和删除密钥。每个后端最多可以同时配置三个密钥。我们建议您定期轮换密钥,即删除最旧的密钥,然后添加新密钥,并在为网址或 Cookie 签名时使用新密钥。

您可以在多个后端服务和后端存储分区中使用相同的密钥名称,因为各组密钥是彼此独立的。密钥名称最长为 63 个字符。要命名密钥,请使用字符 A-Z、a-z、0-9、_(下划线)、-(连字符)。

创建密钥后,务必妥善保管好密钥,因为只要拥有其中一个密钥,任何人都可以创建 Cloud CDN 接受的签名网址或签名 Cookie,直到该密钥从 Cloud CDN 中删除为止。密钥存储在生成签名网址或签名 Cookie 的计算机上。Cloud CDN 也会存储这些密钥以验证请求签名。

为了确保密钥的机密性,请勿在任何 API 请求的响应中包含密钥值。如果您丢失了某个密钥,则必须创建一个新密钥。

如需创建已签名的请求密钥,请按以下步骤操作。

控制台

  1. 在 Google Cloud 控制台中,转到 Cloud CDN 页面。

    转到 Cloud CDN

  2. 点击您要为其添加密钥的来源的名称。
  3. 来源详情页面上,点击修改按钮。
  4. Origin basics(来源基本信息)部分中,点击下一步以打开主机和路径规则部分。
  5. 主机和路径规则部分中,点击下一步以打开缓存性能部分。
  6. 受限内容部分中,选择使用签名网址和签名 Cookie 限制访问权限
  7. 点击添加签名密钥

    1. 为新签名密钥指定一个唯一的名称。
    2. 密钥创建方法部分中,选择自动生成。或者,点击让我输入,然后指定签名键值。

      对于前一个选项,请将自动生成的签名密钥值复制到一个私有文件中,您可以使用该值来创建签名网址

    3. 点击完成

    4. 缓存条目最长存在时间部分,输入一个值,然后选择一个时间单位。

  8. 点击完成

gcloud

gcloud 命令行工具会从您指定的本地文件中读取密钥。密钥文件必须按照如下方式创建:生成强随机 128 位代码,使用 base64 对该代码进行编码,然后将字符 + 替换为 -、将字符 / 替换为 _。如需了解详情,请参阅 RFC 4648。必须确保密钥具有强随机性。在类似 UNIX 的系统上,您可以使用以下命令生成强随机密钥并将密钥存储在密钥文件中:

head -c 16 /dev/urandom | base64 | tr +/ -_ > KEY_FILE_NAME

如需将密钥添加到后端服务,请执行以下操作:

gcloud compute backend-services \
   add-signed-url-key BACKEND_NAME \
   --key-name KEY_NAME \
   --key-file KEY_FILE_NAME

如需将密钥添加到后端存储分区,请执行以下操作:

gcloud compute backend-buckets \
   add-signed-url-key BACKEND_NAME \
   --key-name KEY_NAME \
   --key-file KEY_FILE_NAME

配置 Cloud Storage 权限

如果您使用 Google Cloud Storage,并且限制了可以读取对象的用户,则必须向 Cloud CDN 授予读取对象的权限,方法是将 Cloud CDN 服务账号添加到 Cloud Storage ACL 中。

您无需创建服务账号。当您首次将密钥添加到项目中的后端存储分区时,系统将自动创建服务账号。

在运行以下命令之前,请向项目中的后端存储分区添加至少一个密钥。否则,该命令会失败并显示错误,这是因为您必须先为项目添加一个或多个密钥,系统才会创建 Cloud CDN 缓存填充服务账号。

gcloud storage buckets add-iam-policy-binding gs://BUCKET \
  --member=serviceAccount:service-PROJECT_NUM@cloud-cdn-fill.iam.gserviceaccount.com \
  --role=roles/storage.objectViewer

PROJECT_NUM 替换为您的项目编号,将 BUCKET 替换为您的存储桶。

Cloud CDN 服务账号 service-PROJECT_NUM@cloud-cdn-fill.iam.gserviceaccount.com 不会出现在您项目的服务账号列表中。这是因为 Cloud CDN 服务账号归 Cloud CDN 所有,不属于您的项目。

如需详细了解项目编号,请参阅 Google Cloud 控制台帮助文档中的查找项目 ID 和项目编号

自定义最长缓存时间

无论后端的 Cache-Control 标头如何,Cloud CDN 都会缓存针对签名请求的响应。响应无需重新验证的最长缓存时间由 signed-url-cache-max-age 标志设置,默认设为 1 小时,您可以按照此处所示的方法进行修改。

要设置后端服务或后端存储分区的最长缓存时间,请运行以下命令之一:

gcloud compute backend-services update BACKEND_NAME
  --signed-url-cache-max-age MAX_AGE
gcloud compute backend-buckets update BACKEND_NAME
  --signed-url-cache-max-age MAX_AGE

列出签名请求密钥名称

要列出后端服务或后端存储分区上的密钥,请运行以下命令之一:

gcloud compute backend-services describe BACKEND_NAME
gcloud compute backend-buckets describe BACKEND_NAME

删除签名请求密钥

如果采用特定密钥签名的网址不再受到支持,请运行以下某个命令以从后端服务或后端存储分区中删除该密钥:

gcloud compute backend-services \
   delete-signed-url-key BACKEND_NAME --key-name KEY_NAME
gcloud compute backend-buckets \
   delete-signed-url-key BACKEND_NAME --key-name KEY_NAME

创建政策

签名 Cookie 政策就是一系列 key-value 对(由 : 字符分隔),类似于签名网址中使用的查询参数。 如需查看示例,请参阅向用户签发 Cookie

政策表示请求对其有效的参数。使用基于哈希的消息身份验证代码 (HMAC) 对政策进行签名,Cloud CDN 会针对每条请求验证 HMAC。

定义政策格式和字段

您必须按以下顺序定义四个必填字段:

  • URLPrefix
  • Expires
  • KeyName
  • Signature

签名 Cookie 政策中的 key-value 对区分大小写。

URLPrefix

URLPrefix 表示可在网址中安全使用的 Base64 编码网址前缀,其中包含该签名应对其有效的所有路径。

URLPrefix 会对架构(http://https://)、FQDN 和可选路径进行编码。您可以选择是否使用以 / 结尾的路径,但我们建议您使用。前缀不应包含查询参数或者 ?# 这样的片段。

例如,https://media.example.com/videos 会将请求匹配到以下两项:

  • https://media.example.com/videos?video_id=138183&user_id=138138
  • https://media.example.com/videos/137138595?quality=low

前缀的路径作为文本子字符串使用,而不是严格意义上的目录路径。 例如,前缀 https://example.com/data 会授予对以下两个路径的访问权限:

  • /data/file1
  • /database

要避免这种错误,我们建议您在所有前缀的结尾处使用 /,除非您有意选择使用部分文件名(例如 https://media.example.com/videos/123)作为前缀的结尾,以授予对以下路径的访问权限:

  • /videos/123_chunk1
  • /videos/123_chunk2
  • /videos/123_chunkN

如果请求的网址与 URLPrefix 不匹配,Cloud CDN 会拒绝请求并向客户端返回一条 HTTP 403 错误。

Expires

Expires 必须是 Unix 时间戳(自 1970 年 1 月 1 日以来的秒数)。

KeyName

KeyName 是对后端存储分区或后端服务创建的密钥的名称。密钥名称区分大小写。

Signature

Signature 是构成 Cookie 政策的各字段的签名,采用可在网址中安全使用的 base64 编码的 HMAC-SHA-1 签名格式。系统会对每条请求进行签名验证,而且会拒绝带有无效签名的请求并显示 HTTP 403 错误。

以编程方式创建签名 Cookie

以下代码示例演示了如何以编程方式创建签名 Cookie。

Go

import (
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base64"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"time"
)

// signCookie creates a signed cookie for an endpoint served by Cloud CDN.
//
// - urlPrefix must start with "https://" and should include the path prefix
// for which the cookie will authorize access to.
// - key should be in raw form (not base64url-encoded) which is
// 16-bytes long.
// - keyName must match a key added to the backend service or bucket.
func signCookie(urlPrefix, keyName string, key []byte, expiration time.Time) (string, error) {
	encodedURLPrefix := base64.URLEncoding.EncodeToString([]byte(urlPrefix))
	input := fmt.Sprintf("URLPrefix=%s:Expires=%d:KeyName=%s",
		encodedURLPrefix, expiration.Unix(), keyName)

	mac := hmac.New(sha1.New, key)
	mac.Write([]byte(input))
	sig := base64.URLEncoding.EncodeToString(mac.Sum(nil))

	signedValue := fmt.Sprintf("%s:Signature=%s",
		input,
		sig,
	)

	return signedValue, nil
}

Java

import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.time.ZonedDateTime;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class SignedCookies {

  public static void main(String[] args) throws Exception {
    // TODO(developer): Replace these variables before running the sample.

    // The name of the signing key must match a key added to the back end bucket or service.
    String keyName = "YOUR-KEY-NAME";
    // Path to the URL signing key uploaded to the backend service/bucket.
    String keyPath = "/path/to/key";
    // The Unix timestamp that the signed URL expires.
    long expirationTime = ZonedDateTime.now().plusDays(1).toEpochSecond();
    // URL prefix to sign as a string. URL prefix must start with either "http://" or "https://"
    // and must not include query parameters.
    String urlPrefix = "https://media.example.com/videos/";

    // Read the key as a base64 url-safe encoded string, then convert to byte array.
    // Key used in signing must be in raw form (not base64url-encoded).
    String base64String = new String(Files.readAllBytes(Paths.get(keyPath)),
        StandardCharsets.UTF_8);
    byte[] keyBytes = Base64.getUrlDecoder().decode(base64String);

    // Create signed cookie from policy.
    String signedCookie = signCookie(urlPrefix, keyBytes, keyName, expirationTime);
    System.out.println(signedCookie);
  }

  // Creates a signed cookie for the specified policy.
  public static String signCookie(String urlPrefix, byte[] key, String keyName,
      long expirationTime)
      throws InvalidKeyException, NoSuchAlgorithmException {

    // Validate input URL prefix.
    try {
      URL validatedUrlPrefix = new URL(urlPrefix);
      if (!validatedUrlPrefix.getProtocol().startsWith("http")) {
        throw new IllegalArgumentException(
            "urlPrefix must start with either http:// or https://: " + urlPrefix);
      }
      if (validatedUrlPrefix.getQuery() != null) {
        throw new IllegalArgumentException("urlPrefix must not include query params: " + urlPrefix);
      }
    } catch (MalformedURLException e) {
      throw new IllegalArgumentException(
          "urlPrefix malformed: " + urlPrefix);
    }

    String encodedUrlPrefix = Base64.getUrlEncoder().encodeToString(urlPrefix.getBytes(
        StandardCharsets.UTF_8));
    String policyToSign = String.format("URLPrefix=%s:Expires=%d:KeyName=%s", encodedUrlPrefix,
        expirationTime, keyName);

    String signature = getSignatureForUrl(key, policyToSign);
    return String.format("Cloud-CDN-Cookie=%s:Signature=%s", policyToSign, signature);
  }

  // Creates signature for input string with private key.
  private static String getSignatureForUrl(byte[] privateKey, String input)
      throws InvalidKeyException, NoSuchAlgorithmException {

    final String algorithm = "HmacSHA1";
    final int offset = 0;
    Key key = new SecretKeySpec(privateKey, offset, privateKey.length, algorithm);
    Mac mac = Mac.getInstance(algorithm);
    mac.init(key);
    return Base64.getUrlEncoder()
        .encodeToString(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
  }
}

Python

import argparse
import base64
from datetime import datetime, timezone
import hashlib
import hmac
from urllib.parse import parse_qs, urlsplit


def sign_cookie(
    url_prefix: str,
    key_name: str,
    base64_key: str,
    expiration_time: datetime,
) -> str:
    """Gets the Signed cookie value for the specified URL prefix and configuration.

    Args:
        url_prefix: URL prefix to sign.
        key_name: name of the signing key.
        base64_key: signing key as a base64 encoded string.
        expiration_time: expiration time as time-zone aware datetime.

    Returns:
        Returns the Cloud-CDN-Cookie value based on the specified configuration.
    """
    encoded_url_prefix = base64.urlsafe_b64encode(
        url_prefix.strip().encode("utf-8")
    ).decode("utf-8")
    epoch = datetime.fromtimestamp(0, timezone.utc)
    expiration_timestamp = int((expiration_time - epoch).total_seconds())
    decoded_key = base64.urlsafe_b64decode(base64_key)

    policy = f"URLPrefix={encoded_url_prefix}:Expires={expiration_timestamp}:KeyName={key_name}"

    digest = hmac.new(decoded_key, policy.encode("utf-8"), hashlib.sha1).digest()
    signature = base64.urlsafe_b64encode(digest).decode("utf-8")

    signed_policy = f"Cloud-CDN-Cookie={policy}:Signature={signature}"

    return signed_policy

验证签名 Cookie

验证签名 Cookie 的过程与生成签名 Cookie 大致相同。例如,假设您想要验证以下签名 Cookie 标头:

Cookie: Cloud-CDN-Cookie=URLPrefix=URL_PREFIX:Expires=EXPIRATION:KeyName=KEY_NAME:Signature=SIGNATURE; Domain=media.example.com; Path=/; Expires=Tue, 20 Aug 2019 02:26:49 GMT; HttpOnly

您可以使用 KEY_NAME 命名的密钥单独生成签名,然后验证该签名是否与 SIGNATURE 匹配。

向用户签发 Cookie

您的应用必须为每个用户(客户端)生成并签发一个包含正确签名政策的 HTTP Cookie。

  1. 在应用代码中创建一个 HMAC-SHA-1 签名者。

  2. 使用所选密钥为政策签名,并记下您添加到后端的密钥名称,例如 mySigningKey

  3. 创建具有以下格式的 Cookie 政策,注意其名称和值都区分大小写:

    Name: Cloud-CDN-Cookie
    Value: URLPrefix=$BASE64URLECNODEDURLORPREFIX:Expires=$TIMESTAMP:KeyName=$KEYNAME:Signature=$BASE64URLENCODEDHMAC
    

    Set-Cookie 标头示例:

    Set-Cookie: Cloud-CDN-Cookie=URLPrefix=aHR0cHM6Ly9tZWRpYS5leGFtcGxlLmNvbS92aWRlb3Mv:Expires=1566268009:KeyName=mySigningKey:Signature=0W2xlMlQykL2TG59UZnnHzkxoaw=; Domain=media.example.com; Path=/; Expires=Tue, 20 Aug 2019 02:26:49 GMT; HttpOnly
    

    Cookie 中的 DomainPath 属性决定了客户端是否将 Cookie 发送到 Cloud CDN。

建议和要求

  • 明确设置 DomainPath 属性,以匹配您希望从中传送受保护内容的网域和路径前缀,这可能与签发 Cookie 的网域和路径不同(分别是 example.commedia.example.com,或者分别是 /browse/videos)。

  • 对于同一个 DomainPath,请确保您只有一个具有指定名称的 Cookie。

  • 请确保您未签发有冲突的 Cookie,否则可能导致无法在其他浏览器会话(窗口或标签页)中访问内容。

  • 在适用的情况下设置 SecureHttpOnly 标志。 Secure 可确保仅通过 HTTPS 连接发送 Cookie。HttpOnly 会禁止 JavaScript 使用 Cookie。

  • Cookie 特性 ExpiresMax-Age 是可选的。如果您省略这些特性,则 Cookie 会在浏览器会话(标签页或窗口)存在期间保持存在。

  • 在缓存填充或缓存未命中时,签名 Cookie 会传递到后端服务中定义的源站。请确保首先验证每个请求的签名 Cookie 值,之后再传送内容。