CloudFront签名上手:使用CloudFront做S3存储桶的私有内容分发

本文的效果演示Demo视频参考这里

一、背景

1、传统企业与内容分发

以往,私有内容分发一直是数字原生的互联网行业的技术需求,广泛用于经过会员体系验证的版权内容分发,包括但不限于视频播放、音频播放、游戏下载、软件分发等。

如今,随着传统企业的数字化转型越来越普遍,大量企业内部应用技术栈全面互联网化,许多企业的应用系统已经突破了传统的VPN内网概念,转而在互联网上运行。企业日常运营产生各种流程文档、数据文件、日志等需要被分发给员工和第三方合作伙伴。这种场景下如何能有保护地企业私有内容的安全分发,就成为了企业数字化转型的安全关键。

2、使用CloudFront签名的场景

大部分企业配置CloudFront时候是按照公开访问来分发的,场景相对较广,通常分发对象是网页和APP中嵌入的图片、视频、CSS、JS代码。这些资源可以通过URL地址在互联网上被公开地、匿名地访问。

而私有内容的场景一般指经过用户授权才可以访问的,例如互联网领域的购买电子书、订阅的软件更新、会员视频观看等场景。对应传统企业数字化转型的落地的应用,通常指企业内部运营文档、报告、扫描件影印件、数据文件、配置文件、日志等文件。

私有内容分发的安全需求是抵御未经授权的访问、盗链和DDOS攻击。如果恶意的访问者将私有内容的访问链接分发到别的平台上,首先会造成企业运营隐私的泄密,同时对整个软件平台带来额外流量负载,可能使系统无法正常响应业务。同时,盗链等非授权访问导致网络流量的上涨,由此导致云使用成本飙升。

因此,使用包括CloudFront签名为主的技术手段对要分发的内容进行防护是十分必要的。当启用CloudFront签名功能后,现有整个发布点的所有请求都只接受签名验证成功的访问,不接受匿名访问。由此,大大提升了分发内容的私密性和安全性。

3、在S3签名和CloudFront签名之间选择

亚马逊云科技的存储服务S3作为数据湖的核心,能满足多种功能类型的图片、图像、视频、文档、日志的存储、分析、检索等功能。在S3上,也提供了被称为Pre-Sign URL的签名功能,用于向用户提供需要授权场景下的访问。那么如何在S3 Pre-Sign URL和CloudFront签名之间做出选择呢?

二者具体对比如下。

S3 Pre-Sign URLCloudFront签名
证书S3服务生成证书,用户调用S3接口生成签名URL用户自行生成证书,应用层计算获得签名,然后在云端校对
源站S3对外提供服务要求文件必须在S3上而CloudFront签名机制允许被分发的原始文件在ELB、S3、EC2或者其他外部源等多种功能位置
保护S3 Pre-sign URL可以保护单个文件,但会暴露存储桶的名称、路径等信息,可能会继续引发穷举遍历等攻击CloudFront可实现整个发布点的强制签名检查,不怕扫描和遍历
行为S3仅支持Pre-signed URL,因此应用系统内已经存在的S3文件访问路径都需要变更,末尾需要加上一串token,这对已经设计好的应用可能存在影响CloudFront的Signed-URL签名方式也需要在末尾加上token;使用Signed Cookie签名则不改动URL
加速S3是单个region的服务,如果访问者位于全球各地,还需要开启S3 Transfer Acceleration ,而S3传输加速由会产生在普通S3 DTO之外的额外加速流量费,成本相对较高CloudFront加速点,覆盖好,此外本身CloudFront带有缓存能力,对源站无压力,适合大量分发;流量费成本很低
授权S3只能限制文件过期时间,不支持设置授权开始的起始时间;此外S3签名不支持针对访问者IP的详细控制,只能在全桶级别限制访问IP,局限较大CloudFront签名可以限制文件起始时间、结束时间、IP地址等,此外还可结合Route53和CloudFront的地理位置功能做多种策略
安全S3对外直接提供访问不支持AWS WAF服务集成,无法设置针对文件扩展名的规则,无法检查各种HTTP头,安全特性较差CloudFront支持WAF集成,可实现多种策略防护。对于WAF不能直接满足的安全检查和校验,还可以使用CloudFront Function或Lambda@Edge等边缘计算实现

通过以上对比可以看出,当要分发的源文件在S3上时候,使用CloudFront的签名功能替代S3签名是更好的选择。

4、CloudFront后端的S3源站保护

CloudFront服务去访问内容所在的源站的过程被称为回源。在CloudFront分发私有内容的场景中,被分发的文件所在的源站也需要进行保护。当CloudFront回源时候,S3存储桶本身不需要被设置为公开访问。S3存储桶可以继续保持Private私有状态,然后CloudFront服务可以使用特别的身份认证机制访问私有的S3存储桶。

CloudFront对S3源站保护功能之前采用源访问身份Origin Access Identity(简称OAI)机制。由于OAI在访问策略授权时候是采用的统一的OAI身份,但是不能具体区分到单一发布点,因此在同一个存储桶同时配置2个CloudFront发布点时候(其中一个开启签名、另一个不开启),此时会存在权限管理无法细分的情况。由此,2022年起被新的源访问控制功能Origin Access Control (OAC)所取代。

一个典型的OAC需要在S3存储桶策略界面上配置如下策略:

{
    "Version": "2012-10-17",
    "Statement": {
        "Sid": "AllowCloudFrontServicePrincipalReadOnly",
        "Effect": "Allow",
        "Principal": {
            "Service": "cloudfront.amazonaws.com"
        },
        "Action": "s3:GetObject",
        "Resource": "arn:aws:s3:::DOC-EXAMPLE-BUCKET/*",
        "Condition": {
            "StringEquals": {
                "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
            }
        }
    }
}

由此即可将S3存储桶设置为仅接受CloudFront特定发布点的回源访问。

二、CloudFront签名的使用方式

1、在Signed URL或者Signed Cookie之间做选择

CloudFront签名使用的算法是RSA-SHA1,目前CloudFront不支持其它算法。在签名完成后,客户端与CloudFront进行认证有两种身份认证方式,分别是Signed-URL和Signed Cookie。

当使用Signed-URL时候,一个访问请求格式类似如下(客户端无需支持Cookie):

https://xxx.xxx.com/video/xxx.mp4

当使用Signed Cookie时候,一个访问请求公司类似如下如下(要求客户端必须支持Cookie):

https://xxx.xxx.com/video/xxx.mp4

同时Cookie信息如下:

Set-Cookie: 
CloudFront-Expires=date and time in Unix time format (in seconds) and Coordinated Universal Time (UTC); 
Domain=optional domain name; 
Path=/optional directory path; 
Secure; 
HttpOnly

Set-Cookie: 
CloudFront-Signature=hashed and signed version of the policy statement; 
Domain=optional domain name; 
Path=/optional directory path; 
Secure; 
HttpOnly

Set-Cookie: 
CloudFront-Key-Pair-Id=public key ID for the CloudFront public key whose corresponding private key you're using to generate the signature; 
Domain=optional domain name; 
Path=/optional directory path; 
Secure; 
HttpOnly

两个方式主要在于客户端访问CloudFront服务时候的认证方式不同。生成二者签名的是同一个算法,因此在开发过程中,可一次签名生成Token等信息,对于不同文件类型按需使用。签名请求可使用多种常见语言包括Java、Python、PHP等生成签名。由此可以看到,如果不希望请求的文件名带上签名的token,只是希望保留原始文件名,那么可选择用Signed Cookie方式。如果不介意请求URI的长度,或者是访问CloudFront的客户端不支持Cookie,那么使用Signed-URL更简单。

需要注意的是,如果同时使用两种方式,在请求的URL上带有签名字符串,又设置了对应的Cookie,此时只有URL地址生效,Cookie无效。这个结论出处来自本文末尾的参考文档。

2、在Canned Policy或者Custom Policy两类策略中做出选择

CloudFront签名对私有文件的授权有两种策略,一种是Canned Policy,也被称为标准策略;另一种是Custom Policy自定义策略。

二者的区别是:

对比Canned PolicyCustom Policy
限制资源过期时间支持支持
其他限制参数不支持支持限制资源路径、通配符,限制起始时间,限制访问者IP地址
必须传递的参数CloudFront-Key-Pair-Id, CloudFront-SignatureCloudFront-Key-Pair-Id, CloudFront-Signature
特殊传递参数CloudFront-ExpiresCloudFront-Policy
使用Sign-URL时候总URI长度
推荐场景测试、验证签名生产环境

通过以上对比表格可以看出,如果希望是简单测试签名场景,可通过Canned Policy或称为标准策略进行签名。如果是在生产环境中,分发企业私有内容,例如对开始时间、到期时间、访问者IP等进行严格限制,那么则适合使用Custom Policy。在生产环境中推荐使用Custom Policy。

接下来进行配置。

三、为CloudFront发布点开启签名功能

1、签署SSL证书并上传到CloudFront

首先使用Amazon Linux 2系统的openssl库生成证书,然后再导出公有证书,执行如下命令:

openssl genrsa -out CloudFront-Workshop-Private-Key.pem 2048
openssl rsa -pubout -in CloudFront-Workshop-Private-Key.pem -out CloudFront-Workshop-Public-Key.pem

返回结果如下:

[ec2-user@ip-172-31-16-68 ~]$ openssl genrsa -out CloudFront-Workshop-Private-Key.pem 2048
Generating RSA private key, 2048 bit long modulus
.....................+++
..........+++
e is 65537 (0x10001)
[ec2-user@ip-172-31-16-68 ~]$ openssl rsa -pubout -in CloudFront-Workshop-Private-Key.pem -out CloudFront-Workshop-Public-Key.pem
writing RSA key
[ec2-user@ip-172-31-16-68 ~]$ 

由此获得了Public和Private两个Key。其中Public Key要被上传到CloudFront界面。而Private Key将放在能被签名代码调用的位置下。请注意防护目录权限,不要错误配置导致证书通过WEB下载造成泄露。

进入CloudFront界面,点击左侧的Key management菜单下的的Public keys菜单。点击新建按钮,上传刚才生成的Public Key。在上方输入名称,在下方粘贴上Key的内容。然后点击右下角的Create public key按钮完成创建。如下截图。

接下来创建Key groups。为了方便管理,一个CloudFront分发点支持配置为一组Key。因此如果有多个应用,可以分别将各自的Key加入到Key groups,就可以在同一个分发点同时使用多个Key完成签名和验证。

点击左侧的Key management菜单下的的Key groups菜单。点击新建按钮,上传刚才生成的输入Key groups的名字,然后从Public keys的下拉框中挑选出来上一步生成的Key的名字。最后点击右下角的Create key group的按钮完成创建。如下截图。

至此证书配置完成。

2、将现有CloudFront发布点配置为私有,并开启开启签名校验

本文假设此前已经有一个配置好的CloudFront发布点,其源站是S3存储桶。并且S3存储桶是非公开状态,且CloudFront使用Origin Access Control(OAC)或Origin Access Identity(OAI)访问S3存储桶。如果当前环境不满足这个条件,则意味着恶意访问者有可能绕过CloudFront,直接通过S3公开访问获取文件,这样也就失去了保护私有内容的意义。因此S3存储桶需要开启OAC或者OAI功能,仅允许来自CloudFront的回源访问。OAC或OAI的配置过程可以参考 官方文档 关闭S3存储桶的公开状态、将其修改为私有、并配置为S3仅允许CloudFront访问。

如果您的源站不是S3,而是自行部署的EC2、ELB等环境,或者是AWS云之外的环境,那么您需要自行管理源站的访问授权,以免有非签名的流量进入。

在满足以上条件后,可以将CloudFront发布点从无需签名的状态修改喂需要签名的状态。找到要开启签名的CloudFront分发点,在第三个标签页Behaviors行为下,选中默认行为,点击编辑按钮。如下截图。

进入编辑行为界面后,在Viewer菜单下,找到Restrict viewer access,默认是No,这里改成Yes。在下方Trusted authorization type位置,选中Trusted key groups (recommended),让偶从下拉框中选择上一步创建的Key group的名字,然后点击页面最下方的保存修改设置按钮。如下截图。

3、测试发布点签名功能启用成功

在发布点配置变更完成后,可使用不带有签名、不带有Cookie的访问去测试,验证发布点是否验证签名。

如果返回结果是:

<Error>
<Code>MissingKey</Code>
<Message>Missing Key-Pair-Id query parameter or cookie value</Message>
</Error>

则表示现在CDN发布点必须要求签名才可以访问。

注意:在完成上述配置的一刻,所有针对本CloudFront分发点(源站是S3)的匿名访问,也就是不包含签名的访问将全部被拒绝。只有将签名放在URL中(Sign-URL)或者放在Cookie中(Signed Cookie)才可以正常访问。因此在生产环境进行变更前,请充分考虑蓝绿测试和配置变更流程,以免影响生产环境运行。

至此CloudFront界面上的配置完成。接下来我们来看生成签名的应用代码,并测试访问。

四、签名代码示例

1、使用Python标准策略签署Signed-URL

在要执行Python代码的环境安装通过pip安装软件包:

pip3 install cryptography boto3

构建如下一段python。这段代码可从本文末尾参考文档的Github代码仓库中获取。代码如下:

import datetime

### pip3 install cryptography boto3

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from botocore.signers import CloudFrontSigner

### Please replace with your information

key_id = "ABCDEFGHABCDE"
url = "https://videocdn.yourdomain.com/video/content.mp4"
private_key_filename = "/home/ec2-user/yourprviatekey.pem"
expire_date = datetime.datetime(2023, 1, 20)

def rsa_signer(message):
    with open(private_key_filename, 'rb') as key_file:
        private_key = serialization.load_pem_private_key(
            key_file.read(),
            password=None,
            backend=default_backend()
        )
    return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())

cloudfront_signer = CloudFrontSigner(key_id, rsa_signer)

# Create a signed url that will be valid until the specific expiry date
# provided using a canned policy.
signed_url = cloudfront_signer.generate_presigned_url(
    url, date_less_than=expire_date)
print(signed_url)

请注意以上代码中的过期时间,设置为’2023,1,21’表示将在2023年1月21日0点0分过期。因此日期需要设置在当前运行时间以后的未来时间。

执行后结果如下:

[ec2-user@ip-172-31-16-68 ~]$ python sign.py
https://blogimg2.bitipcman.com/video/CloudWatch-Log-Groups-Metric-Alarm-2.mp4

然后通过浏览器访问这个网址,即可正常获取内容。这里通过CURL测试下带着签名的访问,并将文件下载到本地:

[ec2-user@ip-172-31-16-68 ~]$ curl "https://blogimg2.bitipcman.com/video/CloudWatch-Log-Groups-Metric-Alarm-2.mp4" --output download.mp4
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 20.6M  100 20.6M    0     0  73.2M      0 --:--:-- --:--:-- --:--:-- 73.1M
[ec2-user@ip-172-31-16-68 ~]$ 

测试通过,访问正常。

如果是没有带任何Token直接访问文件,则CloudFront会提示:

<Error>
<Code>InvalidKey</Code>
<Message>Unknown Key</Message>
</Error>

如果是授权的时间超过有效期了,则CloudFront会提示:

<Error>
<Code>AccessDenied</Code>
<Message>Access denied</Message>
</Error>

至此Python代码的签名测试完成。下面测试其他语言。

2、使用PHP针对Canned Policy标准策略和Custom Policy自定义策略分别生成Signed URL签名

使用本文末尾的参考文档中的Github上的PHP代码样例,修改config.php其中的关键参数,然后通过浏览器访问网页即可获取签名。为了验证访问正常,可将签名URL复制下来,粘贴到下方的网页播放器内,即可验证签名是否工作正常。如下截图。

PHP生成签名的过程、访问体验与前文的Python过程完全相同。二者在请求文件时候地址上都需要带着这一长串的签名。前文分析Signed-URL和Signed Cookie时候介绍过二者的区别,因为很多应用访问希望保持原始文件名,不包含签名这一长串token,因此这个时候Cookie方式传递签名就变得很方便。下文介绍Signed Cookie。

3、使用PHP针对Canned Policy标准策略生成Signed Cookie签名

前文已经介绍过Cookie的格式要求,这里以PHP语言为例,代码可从本文末尾参考文档的Github代码仓库中获取。设置Cookie的代码如下:

// set cookie
setcookie("CloudFront-Expires", "$expires", 0, "/", "$cookie_domain", false, true);
setcookie("CloudFront-Signature", "$signature_in_base64", 0, "/", "$cookie_domain", false, true);
setcookie("CloudFront-Key-Pair-Id", "$key_pair_id", 0, "/", "$cookie_domain", false, true);

在这段代码中,第一个字段例如CloudFront-Expires是Cookie名称;第二个字段是Cookie的Value(值),其来源是PHP生成的变量;第三个字段设置为0时候表示Cookie过期是随浏览器session关闭就过期;第四个字段是Cookie生效路径;第五个字段是Cookie生效的Domain域名;最后两个字段表示接受非Https的http请求。

在这些变量中,第四个字段Cookie剩下的域名是非常关键的一个字段。Cookie是不能跨域的,因此一般建议应用程序的主站和提供私有内容访问的资源的网站分别使用顶级域名和二级子域名。这样在配置Cookie时候,只要针对顶级域名配置Cookie即可。如果因为一些历史遗留问题,应用程序的主站和提供私有内容访问的资源的网站使用了完全不同的域名,那么就无法直接设置Cookie,还需要借助包括但不限于iframe方式在内的多种方式跨站设置Cookie。这部分内容不在本文讨论之列。

当Cookie生成后,可以在浏览器上按F12键唤出开发工具,然后可查看当前生效的Cookie名称、值、域名等。如下截图。

当一切配置正确后,用户从浏览器上直接点击要分发的私有内容的链接,将可以正常访问。

4、使用PHP针对Custom Policy自定义策略生成Signed Cookie签名

上文是PHP语言针对Canned Policy标准策略生成Cookie,这里单独把Custom Policy策略拿出来作为一个独立小标题介绍,是因为自定义策略使用的Cookie名称有所不同。

使用Custom Policy自定义策略时,Cookie字段不再包含CloudFront-Expires,而是改为CloudFront-Policy。这个CloudFront-Policy的定义是包含了Expires停止时间的。这里以PHP语言定义一段JSON为例,策略内容如下:

// Custom policy with IP condition for signed-url
$policy =
'{'.
    '"Statement":['.
        '{'.
            '"Resource":"'. $video_path . '",'.
            '"Condition":{'.
                '"IpAddress":{"AWS:SourceIp":"' . $client_ip . '/24"},'.
                '"DateLessThan":{"AWS:EpochTime":' . $expires . '}'.
            '}'.
        '}'.
    ']' .
'}';

以上路径可看出,Custom Policy可定制更多策略管控方式,在资源部分还支持通配符运算。具体写法请参考CloudFront官方文档中自定义策略章节。

自定义策略和签名本身一样都需要经过base64编码才可以放到Cookie中。这里以PHP语言为例,这段代码可从本文末尾参考文档的Github代码仓库中获取。设置Cookie的代码如下:

setcookie("CloudFront-Policy", "$policy_in_base64", 0, "/", "$cookie_domain", false, true);
setcookie("CloudFront-Signature", "$signature_in_base64", 0, "/", "$cookie_domain", false, true);
setcookie("CloudFront-Key-Pair-Id", "$key_pair_id", 0, "/", "$cookie_domain", false, true);

设置完成,现在通过浏览器访问。请先清除掉上一步测试Canned Policy生成的Cookie。清空Cookie后测试Cutom Policy。在浏览器上按F12键唤出开发工具,然后可查看当前生效的Cookie名称、值、域名等。如下截图。

当一切配置正确后,用户从浏览器上直接点击要分发的私有内容的链接,将可以正常访问。

五、小结

基于以上Demo可看出,CloudFront Signed-URL的场景通常从一个网站/应用/APP上直接发起请求,被请求内容是带有签名token的一整串地址。再验证地址通过后,提供私有内容。

CloudFront Signed Cookie使用场景通常是有多个CloudFront分发点,分别绑定不同的二级子域名。第一个CloudFront分发点的源站是ELB,背后是应用程序,这个分发点不开启CloudFront签名。第一个分发点上的应用程序计算生成正确的Cookie并写入到用户侧浏览器上。第二个CloudFront分发点的源站是S3,并且开启CloudFront签名。当用户浏览器从第一个分发点的网页点击跳转加载第二个分发点的私有内容时候,用户请求的就是域名+文件名,请求的URI/地址栏是不包含签名的。此时CloudFront检查浏览器上带有的Cookie是否正确,如正确则提供访问。以上为Signed Cookie使用场景。

在CloudFront签名策略的选择上,本文推荐使用带有更多安全限制功能的Custom Policy精细化管控。

最后,除使用CloudFront签名之外,不要忘记对S3源站开启OAC防护。此外,还可以搭配WAF ACL中的规则包括IP Reputation规则来屏蔽恶意IP来源,以及使用Rate-based规则做限流。综合这些手段,可进一步提升是有内容分发的安全。

六、参考文档

限制S3的访问,从源访问身份 (OAI) 迁移到源访问控制 (OAC):

https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html

在Signed URL和Signed Cookie之间选择:

https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/private-content-choosing-signed-urls-cookies.html

在标准策略和自定义策略之间选择:

https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html

使用标准策略设置Cookie:

https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-canned-policy.html

使用自定义策略设置Cookie:

https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-custom-policy.html

Python代码样例:

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudfront.html#generate-a-signed-url-for-amazon-cloudfront

PHP语言生成Signed-URL官方样例:

https://docs.aws.amazon.com/zhcn/AmazonCloudFront/latest/DeveloperGuide/CreateURLPHP.html

PHP语言生成Signed-URL Canned Policy和Custom Policy以及播放器样例:

https://www.php.net/manual/en/function.setcookie.php

PHP代码签名Signed Cookie Canned Policy和Custom Policy样例:

https://github.com/aobao32/cloudfront-signature-demo

全文完。