ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于STS临时凭证的OSS客户端直传方案与安全实践

基于STS临时凭证的OSS客户端直传方案与安全实践

1. 临时凭证授权的整体设计思路

1.1 为什么不用主账号 AK 直接上传

很多团队一开始图省事,前端直接塞一对主账号 AccessKey,上传下载一把梭。这个做法在内部测试阶段跑得飞快,但只要上线,问题就来了:主账号 AK 权限是全局的,一旦泄露,对方能操作你账号下所有 Bucket,甚至改配置、删数据。更麻烦的是,主账号 AK 无法精细控制"这个用户只能传这个目录"这种粒度。

所以正规做法是走STS(Security Token Service)临时凭证。核心逻辑是:客户端不持有任何长期密钥,而是先向自己的业务服务端要一组"临时通行证",服务端验证用户身份后,用主账号 AK 去 STS 换一组有效期很短(通常 15 分钟到 1 小时)的临时凭证,再下发给客户端。客户端拿着这组临时凭证直接和 OSS 通信,业务服务端全程不转发文件流。

这样设计的好处很直接:主账号 AK 只存在于服务端,永远不下发;临时凭证过期自动失效,即使被截获,攻击窗口也很小;而且可以在签发凭证时用 Policy 精确限定"只能对某个前缀做 PutObject",把权限锁死。

1.2 直传与转发的取舍

另一个关键决策是:文件到底走不走业务服务端?

  • 服务端转发:客户端传给业务服务端,服务端再传给 OSS。优点是逻辑简单、鉴权集中;缺点是服务端带宽和内存压力巨大,大文件场景基本扛不住。
  • 客户端直传:客户端拿临时凭证直接传 OSS。优点是业务服务端零流量压力,扩展性好;缺点是需要处理跨域、凭证刷新、分片等细节。

绝大多数中大型项目最终都会选直传。我个人的经验是:只要单文件可能超过 5MB,或者并发上传用户超过几十个,就该上直传方案,否则服务端迟早成为瓶颈。下面这套方案就是围绕"服务端签发凭证 + 客户端直传"展开的。

1.3 整体链路拆解

把链路拆成四步,理解起来会清晰很多:

  1. 客户端向业务服务端发起请求,带上自己的登录态(比如 Token)。
  2. 业务服务端校验登录态,确认用户身份和可访问的资源范围。
  3. 服务端调用 STS 接口,传入自定义 Policy,换回临时凭证(AccessKeyId、AccessKeySecret、SecurityToken、Expiration)。
  4. 服务端把凭证返回给客户端,客户端用它在有效期内直接操作 OSS。

这里有个容易被忽略的点:Policy 的粒度决定了整个方案的安全上限。如果 Policy 写成"Resource": "*",那和给主账号 AK 没本质区别。真正有价值的做法是把资源路径、操作类型、甚至来源 IP 都限制住。

2. 核心概念与关键参数解析

2.1 STS 返回的四个字段

调用 STS 的 AssumeRole 接口后,返回的核心字段有四个,缺一不可:

字段作用注意事项
AccessKeyId临时身份标识与主账号 AK 格式相同,但生命周期短
AccessKeySecret临时密钥用于签名计算
SecurityToken安全令牌使用临时凭证时必须携带,容易漏
Expiration过期时间UTC 时间,需换算成本地时区

很多人第一次接入时踩的坑就是:只传了 AccessKeyId 和 AccessKeySecret,忘了 SecurityToken,结果一直报签名错误。记住一句话——临时凭证是三件套加一个过期时间,少一个都不行。

2.2 角色(Role)与授权策略的关系

STS 的 AssumeRole 需要指定一个角色 ARN。这个角色本身要有一份"信任策略",允许你的主账号来扮演它;同时角色还要有一份"权限策略",规定扮演后能做什么。而你在调用 AssumeRole 时传入的 Policy,是在角色权限基础上的进一步收窄,两者取交集。

这个"取交集"的特性非常关键。举个例子:角色权限策略允许对bucket-a全部操作,但你在 AssumeRole 时传入的 Policy 只允许bucket-a/upload/user1/*的 PutObject,那么最终临时凭证就只能干这一件事。所以最佳实践是:角色权限给一个较宽的上限,每次签发时用 Policy 精确收窄到当前用户、当前场景。

2.3 Policy 语法要点

Policy 是 JSON 格式,核心结构是 Statement 数组,每条包含 Effect、Action、Resource、Condition。写 Policy 时有几个高频要点:

  • Action 要写具体,比如oss:PutObject、oss:GetObject,不要图省事写oss:*。
  • Resource 的格式是acs:oss:*:*:bucket-name/path/*,注意 bucket 名和路径之间用斜杠。
  • Condition 可以用来限制来源 IP、限制上传文件大小等,是进阶安全手段。

一个典型的收窄 Policy 长这样:

{ "Version": "1", "Statement": [ { "Effect": "Allow", "Action": ["oss:PutObject"], "Resource": ["acs:oss:*:*:my-bucket/upload/${userId}/*"] } ] }

注意${userId}这种占位符不是 Policy 原生支持的,需要你在服务端拼接字符串时替换掉,别直接丢给 STS。

2.4 有效期设置的经验值

Expiration 最短可以设 15 分钟,最长受角色最大会话时长限制。设太短,用户传大文件传到一半凭证过期,分片上传会中断;设太长,安全性下降。

我的经验值是:普通上传场景设 30 分钟,大文件分片上传设 60 分钟。同时客户端要做凭证过期前的自动刷新,比如在过期前 5 分钟重新拉一次。这样既保证安全,又不会让用户感知到中断。

3. 服务端签发凭证的完整实现

3.1 依赖准备与初始化

以常见的后端技术栈为例,需要引入对应的 STS SDK。初始化时用主账号 AK(或更推荐的 RAM 子账号 AK,只授予 AssumeRole 权限),并指定好区域 Endpoint。

from alibabacloud_sts20150401.client import Client as StsClient from alibabacloud_tea_openapi import models as open_api_models config = open_api_models.Config( access_key_id=MAIN_AK, access_key_secret=MAIN_SK, ) config.endpoint = "sts.cn-hangzhou.aliyuncs.com" sts_client = StsClient(config)

这里有个安全细节:用于调用 STS 的主账号 AK 权限要尽可能小,理想情况下只授予sts:AssumeRole这一个权限,即使泄露也无法直接操作 OSS。这是纵深防御的思路。

3.2 动态拼接 Policy

Policy 不能写死,必须根据当前登录用户动态生成。核心是把用户 ID、目标路径拼进 Resource。

def build_policy(user_id, bucket, action="oss:PutObject"): resource = f"acs:oss:*:*:{bucket}/upload/{user_id}/*" policy = { "Version": "1", "Statement": [ { "Effect": "Allow", "Action": [action], "Resource": [resource] } ] } return json.dumps(policy)

拼接时务必对 user_id 做校验,防止用户传入../之类的路径穿越字符,把 Resource 拼成别的目录。这是很多项目翻车的地方——用户 ID 直接来自前端,没做过滤,结果能越权访问别人目录。

3.3 调用 AssumeRole 并返回

def get_sts_credential(user_id, bucket, role_arn): req = models.AssumeRoleRequest( role_arn=role_arn, role_session_name=f"user-{user_id}", policy=build_policy(user_id, bucket), duration_seconds=1800 ) resp = sts_client.assume_role(req) cred = resp.body.credentials return { "accessKeyId": cred.access_key_id, "accessKeySecret": cred.access_key_secret, "securityToken": cred.security_token, "expiration": cred.expiration }

role_session_name 建议带上用户标识,方便在日志审计时追溯是谁申请的凭证。duration_seconds 就是有效期,单位秒。

3.4 接口层的鉴权与限流

签发凭证的接口本身必须严格鉴权,因为它相当于"发钥匙"的窗口。几个必须做的动作:

  • 校验用户登录态,确认是合法用户。
  • 对同一用户的签发频率做限流,防止有人刷接口拿大量凭证。
  • 记录审计日志,包括用户 ID、签发时间、Policy 内容。

提示:签发接口不要做成完全公开的,哪怕带了登录态,也建议加上频率限制。我见过有项目被脚本高频调用,短时间内签发了几万组凭证,虽然每组都受限,但日志和费用都很吓人。

4. 客户端直传的实操流程

4.1 获取凭证与初始化 SDK

客户端拿到凭证后,用这三件套初始化 OSS SDK。以浏览器端为例,初始化时要把 securityToken 一起传进去。

const client = new OSS({ region: 'oss-cn-hangzhou', accessKeyId: cred.accessKeyId, accessKeySecret: cred.accessKeySecret, stsToken: cred.securityToken, bucket: 'my-bucket' });

注意字段名在不同 SDK 里可能略有差异,有的叫stsToken,有的叫securityToken,接入时对照官方文档确认,别想当然。

4.2 普通上传与分片上传的选择

小文件(比如小于 5MB)直接put即可。大文件建议用分片上传,好处是支持断点续传、并发上传、进度回调。

const options = { progress: (p) => console.log(`进度:${p * 100}%`), partSize: 1024 * 1024, parallel: 4 }; await client.multipartUpload(`upload/${userId}/file.zip`, file, options);

partSize 默认 1MB,parallel 是并发数。实测下来,partSize 设 1MB 到 4MB、并发 3 到 5 是比较稳的组合,太小会增加请求数,太大则单分片失败重传成本高。

4.3 凭证过期与自动刷新

分片上传大文件时,很容易遇到凭证过期。SDK 一般支持传入一个刷新回调,在凭证快过期时自动重新获取。

const client = new OSS({ region: 'oss-cn-hangzhou', bucket: 'my-bucket', refreshSTSToken: async () => { const res = await fetch('/api/sts'); const data = await res.json(); return { accessKeyId: data.accessKeyId, accessKeySecret: data.accessKeySecret, stsToken: data.securityToken }; }, refreshSTSTokenInterval: 300000 });

refreshSTSTokenInterval 是刷新间隔,单位毫秒,设 5 分钟比较合适。这个机制能极大降低大文件上传中途失败的概率。

4.4 跨域配置

客户端直传必然涉及跨域。需要在 OSS 控制台给 Bucket 配置 CORS 规则,允许你的域名、允许 PUT/POST/GET 方法、允许必要的 Header。配置时来源不要图省事写*,应该写具体域名,否则等于给所有网站开了直传通道。

5. 常见问题与排查技巧实录

5.1 高频报错速查表

报错信息常见原因解决方向
InvalidAccessKeyId凭证拼写错误或已过期检查三件套是否完整、是否过期
SecurityTokenInvalid漏传 SecurityToken确认 SDK 初始化时传了 token
AccessDeniedPolicy 未覆盖目标路径核对 Resource 路径与实际路径
SignatureDoesNotMatch密钥错误或时间偏差检查 SK、校准服务器时间
CORS 相关错误跨域规则未配置配置 Bucket 的 CORS 规则

5.2 路径不匹配的排查思路

AccessDenied 里最常见的就是路径对不上。排查时把 Policy 里的 Resource 和实际上传的 object key 逐字符对比。特别注意:Policy 里的路径是bucket/upload/user1/*,实际上传的 key 必须是upload/user1/xxx,不能多一层也不能少一层。我踩过的坑是前端拼路径时多带了一个斜杠,导致一直 403,查了半天。

5.3 时间偏差导致的签名失败

签名计算依赖时间戳,如果客户端或服务端系统时间偏差过大(超过 15 分钟),会直接报签名错误。排查时先确认服务器 NTP 同步正常。这个坑在容器环境里尤其常见,某些基础镜像时间没同步,跑起来就报错。

5.4 实操避坑清单

  • Policy 里的 Resource 路径要和实际上传 key 严格一致,建议抽成一个常量统一维护。
  • 用户 ID 拼进路径前必须做白名单校验,防止路径穿越。
  • 签发接口加限流和审计日志,别裸奔。
  • 客户端务必配置凭证自动刷新,大文件场景尤其重要。
  • CORS 来源写具体域名,不要用通配符。
  • 用于调用 STS 的主账号 AK 权限收窄到只剩 AssumeRole。

6. 安全加固与扩展思路

6.1 用 Condition 进一步收窄

Policy 里的 Condition 可以做很多精细控制。比如限制上传文件大小:

"Condition": { "numeric_less_than_equal": { "oss:ContentLength": 10485760 } }

这样即使用户拿到凭证,也无法上传超过 10MB 的文件,防止有人恶意塞大文件刷流量。还可以用IpAddress条件限制来源 IP 段,进一步降低凭证泄露后的风险。

6.2 按业务场景拆分角色

不要所有场景共用一个角色。上传、下载、删除建议拆成不同角色,各自权限策略不同。这样即使某个场景的凭证泄露,影响范围也被限制在单一操作类型内。这是最小权限原则的落地方式。

6.3 回调校验保证文件合法性

客户端直传有个天然问题:文件没经过服务端,服务端不知道用户传了什么。解决办法是配置 OSS 的上传回调,文件上传成功后 OSS 会回调你的服务端,你可以在回调里校验文件类型、大小、甚至做内容审核,校验不通过就拒绝。这样既保留了直传的性能优势,又补上了安全校验环节。

6.4 监控与告警

最后别忘了监控。重点盯几个指标:STS 签发接口的调用频率、签发失败率、OSS 的 403 错误率。签发频率突然飙升往往意味着有人在刷接口;403 飙升可能是 Policy 配置出了问题。把这些指标接入告警,能在问题扩大前发现苗头。

我在实际项目里落地这套方案时,最大的体会是:STS 本身不难,难的是 Policy 的粒度和边界处理。很多安全问题不是出在 STS 调用上,而是出在 Policy 写得太宽、用户输入没校验这些细节上。把这两块盯死,整套直传方案就能既高效又安全地跑起来。后续如果业务量上来,还可以考虑把签发逻辑独立成微服务,配合缓存和限流,支撑更高的并发。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进