阿里云 OSS + STS:安全的文件上传方案
一、引言
在 IM 系统中,文件上传是一个常见需求。用户需要上传图片、音频、视频等文件。传统的做法是将文件先上传到应用服务器,再由服务器转发到云存储,这种方式存在性能瓶颈和安全风险。更优的方案是让客户端直接上传到云存储,但如何保证安全性?本文将介绍如何使用阿里云 OSS + STS 实现安全的文件上传方案
二、为什么使用 STS 而不是永久密钥?
2.1 永久密钥的安全风险
传统方案的问题:
如果使用永久 AccessKey(主账号密钥),通常有两种方式:
方案 1:密钥放在客户端
java
// ❌ 危险:密钥暴露在客户端代码中
const client = new OSS({
accessKeyId: 'LTAI5t...', // 永久密钥
accessKeySecret: 'xxx...', // 永久密钥
bucket: 'my-bucket',
region: 'oss-cn-shenzhen'
});
风险:
- 密钥暴露在客户端代码中,任何人都可以获取
- 一旦泄露,攻击者可以完全控制 OSS 资源
- 无法限制权限,只能使用主账号的全部权限
- 无法撤销,只能更换密钥(影响所有服务)
方案 2:密钥放在服务端,客户端通过服务端上传
客户端 → 服务端 → OSS
问题:
- 服务端成为性能瓶颈,需要中转所有文件
- 占用服务端带宽和存储资源
- 上传大文件时响应慢,用户体验差
2.2 STS 临时凭证的优势
STS(Security Token Service) 是阿里云提供的临时访问凭证服务,具有以下优势:
| 对比项 | 永久密钥 | STS 临时凭证 |
|---|---|---|
| 安全性 | 低(永久有效) | 高(临时有效,自动过期) |
| 权限控制 | 主账号全部权限 | 可精确控制权限范围 |
| 泄露影响 | 严重(需更换密钥) | 轻微(自动过期) |
| 性能 | 需服务端中转 | 客户端直传,性能好 |
| 灵活性 | 低 | 高(可动态生成) |
核心优势:
- 临时性:凭证有效期短(通常 1 小时),过期自动失效
- 权限最小化:可以精确控制允许的操作和资源范围
- 安全性高:即使泄露,影响范围和时间都有限
- 性能好:客户端直接上传到 OSS,不经过服务端
2.3 实际案例对比
场景 :用户上传图片
使用永久密钥(不安全):
风险:密钥泄露 → 攻击者可以:
- 上传任意文件
- 删除所有文件
- 修改文件权限
- 造成数据泄露和经济损失
使用 STS 临时凭证(安全):
优势:
- 凭证 1 小时后自动失效
- 只能上传到指定目录
- 只能执行上传操作
- 即使泄露,影响范围有限
三、STS 临时凭证的获取流程
3.1 整体流程
┌─────────┐ ① 请求临时凭证 ┌─────────┐
│ 客户端 │ ────────────────────────→ │ 服务端 │
│ │ │ │
│ │ ② 调用 STS API │ │
│ │ ←────────────────────────── │ │
│ │ │ │
│ │ ③ 返回临时凭证 │ │
│ │ ←────────────────────────── │ │
│ │ │ │
│ │ ④ 直接上传到 OSS │ │
│ │ ────────────────────────→ │ 阿里云OSS│
│ │ │ │
│ │ ⑤ 返回文件 URL │ │
│ │ ←────────────────────────── │ │
└─────────┘ └─────────┘
3.2 详细步骤
步骤 1:客户端请求临时凭证
// 客户端发送请求
message GetStsCmd {
MsgType msgType = 1; // 消息类型(图片/音频/视频等)
}
步骤 2:服务端调用 STS API
服务端使用主账号的 AccessKey 调用 STS 服务,获取临时凭证:
java
@Component
public class AliOssProvider {
public AliOssStsDto getAliOssSts() {
// 1. 配置 STS 客户端
String endpoint = "sts.cn-hangzhou.aliyuncs.com";
String accessKeyId = "主账号AccessKeyId";
String accessKeySecret = "主账号AccessKeySecret";
DefaultProfile.addEndpoint("", "Sts", endpoint);
IClientProfile profile = DefaultProfile.getProfile("", accessKeyId, accessKeySecret);
DefaultAcsClient client = new DefaultAcsClient(profile);
// 2. 构建 AssumeRole 请求
AssumeRoleRequest request = new AssumeRoleRequest();
request.setSysMethod(MethodType.POST);
request.setRoleArn("acs:ram::123456789:role/oss-upload-role"); // RAM 角色
request.setRoleSessionName("upload-session"); // 会话名称
request.setDurationSeconds(3600L); // 有效期 1 小时
// 3. 调用 STS API
AssumeRoleResponse response = client.getAcsResponse(request);
// 4. 提取临时凭证
AliOssStsDto stsDto = new AliOssStsDto();
stsDto.setAccessKeyId(response.getCredentials().getAccessKeyId());
stsDto.setAccessKeySecret(response.getCredentials().getAccessKeySecret());
stsDto.setSecurityToken(response.getCredentials().getSecurityToken());
stsDto.setBucket("my-bucket");
stsDto.setRegion("oss-cn-shenzhen");
stsDto.setOssEndpoint("oss-cn-shenzhen.aliyuncs.com");
return stsDto;
}
}
步骤 3:返回临时凭证给客户端
protobuf
// 服务端返回
message GetStsAck {
string accessKeyId = 1; // 临时 AccessKeyId
string accessKeySecret = 2; // 临时 AccessKeySecret
string securityToken = 3; // 安全令牌
string region = 4; // 区域
string bucket = 5; // 存储桶名称
string uploadPath = 6; // 上传路径(如:image/2024/01/15)
string endpoint = 7; // OSS 端点
}
步骤 4:客户端直接上传到 OSS
java
// 客户端使用临时凭证上传
const client = new OSS({
accessKeyId: stsDto.accessKeyId,
accessKeySecret: stsDto.accessKeySecret,
stsToken: stsDto.securityToken, // 关键:临时令牌
bucket: stsDto.bucket,
region: stsDto.region,
endpoint: stsDto.endpoint
});
// 上传文件
const result = await client.put(
`${stsDto.uploadPath}/${fileName}`, // 完整路径
file
);
// 获取文件 URL
const fileUrl = result.url;
3.3 关键配置说明
RAM 角色配置(在阿里云控制台):
- 创建 RAM 角色
- 角色名称:oss-upload-role
- 信任实体:当前账号
- 配置角色权限策略
java
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:PutObject",
"oss:GetObject"
],
"Resource": [
"acs:oss:*:*:my-bucket/image/*",
"acs:oss:*:*:my-bucket/audio/*",
"acs:oss:*:*:my-bucket/video/*"
]
}
]
}
权限说明:
- oss:PutObject:允许上传文件
- oss:GetObject:允许下载文件
- Resource:限制只能操作指定路径下的文件
-
获取角色 ARN
格式:acs:ram::{账号ID}:role/{角色名称}
示例:acs:ram::123456789:role/oss-upload-role
四、凭证缓存机制的设计
4.1 为什么需要缓存?
问题场景:
- 用户上传多个文件时,每次都请求 STS 会:
- 增加 STS API 调用次数(有频率限制)
- 增加响应延迟(每次 100-200ms)
- 增加服务端负载
解决方案:
- 缓存临时凭证,多个请求共享同一个凭证
- 减少 STS API 调用
- 提升响应速度
4.2 缓存设计
在 AQChat 项目中,我们使用 Redis 缓存临时凭证:
java
@Component
public class AliOssProvider {
@Resource
private RedisCacheHelper redisCacheHelper;
private static final long CACHE_TIME = 3600 - 60; // 缓存 59 分钟(比凭证有效期少 1 分钟)
public AliOssStsDto getAliOssSts() {
// 1. 先尝试从缓存获取
AliOssStsDto cachedSts = getCacheAliOssSts();
if (cachedSts != null) {
LOGGER.info("从缓存获取 STS 凭证");
return cachedSts;
}
// 2. 缓存未命中,调用 STS API
AliOssStsDto stsDto = callStsApi();
// 3. 存入缓存
if (stsDto != null) {
cacheAliOssSts(stsDto);
}
return stsDto;
}
/**
* 从缓存获取临时凭证
*/
private AliOssStsDto getCacheAliOssSts() {
return redisCacheHelper.getCacheObject(
AQRedisKeyPrefix.ALI_OSS_STS,
AliOssStsDto.class
);
}
/**
* 缓存临时凭证
*/
private void cacheAliOssSts(AliOssStsDto stsDto) {
redisCacheHelper.setCacheObject(
AQRedisKeyPrefix.ALI_OSS_STS,
stsDto,
CACHE_TIME,
TimeUnit.SECONDS
);
}
}
4.3 缓存策略
缓存 Key:
Key: AQChat:aliOssSts
Value: AliOssStsDto (JSON 序列化)
TTL: 3540 秒(59 分钟)
缓存时间设计:
| 项目 | 时间 | 说明 |
|---|---|---|
| STS 凭证有效期 | 3600 秒(1 小时) | 阿里云 STS 返回的凭证有效期 |
| 缓存 TTL | 3540 秒(59 分钟) | 比凭证有效期少 1 分钟 |
| 安全边界 | 60 秒 | 确保凭证过期前缓存已失效 |
设计原因:
- 缓存时间略短于凭证有效期,避免返回已过期的凭证
- 留出 1 分钟的安全边界,确保凭证在使用时仍然有效
4.4 缓存效果
性能提升:
| 指标 | 无缓存 | 有缓存 | 提升 |
|---|---|---|---|
| STS API 调用 | 每次请求 | 每小时 1 次 | 减少 99%+ |
| 响应时间 | 150ms | < 1ms | 提升 150 倍 |
| 并发支持 | 受 STS 限流影响 | 不受限 | 显著提升 |
实际测试数据:
- 1000 个并发请求,无缓存:需要 1000 次 STS 调用,部分请求可能被限流
- 1000 个并发请求,有缓存:只需要 1 次 STS 调用,所有请求从缓存获取
五、文件路径的组织策略
5.1 路径设计原则
- 按类型分类
- 不同类型的文件存储在不同目录
- 便于管理和权限控制
- 按时间分层
- 使用日期组织文件
- 便于清理和归档
- 避免冲突
- 使用唯一文件名(UUID)
- 防止文件覆盖
5.2 路径组织方案
在 AQChat 项目中,我们采用以下路径组织策略:
java
@Component
public class GetStsCmdHandler {
public void handle(ChannelHandlerContext ctx, GetStsCmd cmd) {
// 1. 获取消息类型
int msgTypeValue = cmd.getMsgTypeValue();
String msgType = getMsgTypeByCode(msgTypeValue);
// msgType: "image" / "audio" / "video" / "file"
// 2. 生成上传路径
String uploadPath = msgType + "/" + getFormatTime();
// 示例:image/2024/01/15
// 3. 设置到 STS 凭证中
aliOssSts.setUploadPath(uploadPath);
}
private String getFormatTime() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
return sdf.format(new Date());
}
}
路径结构:
bucket/
├── image/ # 图片文件
│ ├── 2024/
│ │ ├── 01/
│ │ │ ├── 15/
│ │ │ │ ├── uuid1.jpg
│ │ │ │ ├── uuid2.png
│ │ │ │ └── ...
│ │ │ └── 16/
│ │ │ └── ...
│ │ └── 02/
│ │ └── ...
│ └── ...
├── audio/ # 音频文件
│ ├── 2024/
│ │ └── ...
│ └── ...
├── video/ # 视频文件
│ ├── 2024/
│ │ └── ...
│ └── ...
└── file/ # 其他文件
├── 2024/
│ └── ...
└── ...
5.3 路径设计优势
- 便于管理
- 按类型分类,快速定位文件类型
- 按日期分层,便于按时间范围清理
- 权限控制
- 可以为不同目录设置不同的访问权限
- 例如:图片公开访问,文件需要认证
- 性能优化
- 文件分散在不同目录,避免单目录文件过多
- OSS 单目录文件过多会影响性能
- 成本控制
- 可以按目录设置生命周期规则
- 例如:1 年以上的文件自动转为归档存储
5.4 完整文件路径生成
客户端上传时:
java
// 1. 获取 STS 凭证(包含 uploadPath)
const stsResponse = await getStsCredentials(msgType);
// stsResponse.uploadPath = "image/2024/01/15"
// 2. 生成唯一文件名
const fileName = `${generateUUID()}.${getFileExtension(file)}`;
// fileName = "550e8400-e29b-41d4-a716-446655440000.jpg"
// 3. 组合完整路径
const fullPath = `${stsResponse.uploadPath}/${fileName}`;
// fullPath = "image/2024/01/15/550e8400-e29b-41d4-a716-446655440000.jpg"
// 4. 上传到 OSS
const result = await ossClient.put(fullPath, file);
六、安全性考虑
6.1 多层安全防护
- 临时凭证有效期限制
- 凭证有效期 1 小时,过期自动失效
- 即使泄露,影响时间有限
- 权限最小化原则
java
{
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject"], // 只允许上传
"Resource": ["acs:oss:*:*:bucket/image/*"] // 只能上传到指定目录
}
]
}
- 只授予必要的权限(上传)
- 限制资源范围(指定目录)
- 禁止删除、修改等危险操作
- 路径限制
- 服务端控制上传路径
- 客户端无法修改路径,防止目录遍历攻击
- 路径包含日期,便于审计和追踪
- 文件类型验证(可选)
java
// 服务端可以验证文件类型
private boolean isValidFileType(String fileName, MsgType msgType) {
String extension = getFileExtension(fileName);
switch (msgType) {
case IMAGE:
return Arrays.asList("jpg", "jpeg", "png", "gif").contains(extension);
case AUDIO:
return Arrays.asList("mp3", "wav", "aac").contains(extension);
case VIDEO:
return Arrays.asList("mp4", "avi", "mov").contains(extension);
default:
return false;
}
}
6.2 防止常见攻击
-
目录遍历攻击
❌ 危险路径:../../../etc/passwd
✅ 安全路径:image/2024/01/15/uuid.jpg
- 服务端控制路径,客户端无法构造危险路径
-
文件覆盖攻击
✅ 使用 UUID 作为文件名,避免冲突
✅ 路径包含时间,进一步降低冲突概率 -
大文件攻击
// 可以在 RAM 策略中限制文件大小
{
"Condition": {
"NumericLessThan": {
"oss:ContentLength": 10485760 // 限制 10MB
}
}
} -
恶意文件上传
- 客户端上传后,服务端可以异步扫描文件
- 发现恶意文件可以及时删除
- 使用 OSS 的病毒扫描功能
6.3 安全最佳实践
- 主账号密钥保护
- 主账号 AccessKey 只存储在服务端
- 使用环境变量或密钥管理服务(如阿里云 KMS)
- 定期轮换密钥
- RAM 角色权限最小化
- 只授予必要的权限
- 定期审查和更新权限策略
- 使用条件限制(如 IP、时间等)
- 监控和告警
- 监控 STS API 调用频率
- 监控异常上传行为
- 设置告警规则
- 日志记录
java
LOGGER.info("用户{}获取STS凭证,类型:{},路径:{}",
userId, msgType, uploadPath);
- 记录所有 STS 凭证获取请求
- 记录文件上传操作
- 便于审计和问题排查
七、完整实现示例
7.1 服务端实现
java
@Component
public class AliOssProvider {
@Resource
private AQChatConfig config;
@Resource
private RedisCacheHelper redisCacheHelper;
private static final long CACHE_TIME = 3600 - 60; // 59 分钟
/**
* 获取临时凭证(带缓存)
*/
public AliOssStsDto getAliOssSts() {
// 1. 从缓存获取
AliOssStsDto cached = getCacheAliOssSts();
if (cached != null) {
return cached;
}
// 2. 调用 STS API
AliOssStsDto stsDto = callStsApi();
// 3. 缓存结果
if (stsDto != null) {
cacheAliOssSts(stsDto);
}
return stsDto;
}
/**
* 调用 STS API 获取临时凭证
*/
private AliOssStsDto callStsApi() {
try {
// 配置 STS 客户端
DefaultProfile.addEndpoint("", "Sts",
config.getAliOssStsConfig().getEndpoint());
IClientProfile profile = DefaultProfile.getProfile("",
config.getAliOssStsConfig().getAccessKeyId(),
config.getAliOssStsConfig().getAccessKeySecret());
DefaultAcsClient client = new DefaultAcsClient(profile);
// 构建请求
AssumeRoleRequest request = new AssumeRoleRequest();
request.setSysMethod(MethodType.POST);
request.setRoleArn(config.getAliOssStsConfig().getRoleArn());
request.setRoleSessionName(
config.getAliOssStsConfig().getRoleSessionName());
request.setDurationSeconds(
config.getAliOssStsConfig().getDurationSeconds());
// 调用 API
AssumeRoleResponse response = client.getAcsResponse(request);
// 构建返回对象
AliOssStsDto stsDto = new AliOssStsDto();
stsDto.setAccessKeyId(
response.getCredentials().getAccessKeyId());
stsDto.setAccessKeySecret(
response.getCredentials().getAccessKeySecret());
stsDto.setSecurityToken(
response.getCredentials().getSecurityToken());
stsDto.setBucket(config.getAliOssStsConfig().getBucketName());
stsDto.setRegion(config.getAliOssStsConfig().getRegionId());
stsDto.setOssEndpoint(config.getAliOssConfig().getEndpoint());
return stsDto;
} catch (ClientException e) {
LOGGER.error("获取STS凭证失败: {}", e.getErrMsg());
return null;
}
}
private AliOssStsDto getCacheAliOssSts() {
return redisCacheHelper.getCacheObject(
AQRedisKeyPrefix.ALI_OSS_STS,
AliOssStsDto.class);
}
private void cacheAliOssSts(AliOssStsDto stsDto) {
redisCacheHelper.setCacheObject(
AQRedisKeyPrefix.ALI_OSS_STS,
stsDto,
CACHE_TIME,
TimeUnit.SECONDS);
}
}
7.2 客户端实现(JavaScript)
js
// 1. 获取 STS 凭证
async function getStsCredentials(msgType) {
const response = await fetch('/api/sts', {
method: 'POST',
body: JSON.stringify({ msgType })
});
return await response.json();
}
// 2. 上传文件
async function uploadFile(file, msgType) {
// 获取 STS 凭证
const sts = await getStsCredentials(msgType);
// 初始化 OSS 客户端
const client = new OSS({
accessKeyId: sts.accessKeyId,
accessKeySecret: sts.accessKeySecret,
stsToken: sts.securityToken,
bucket: sts.bucket,
region: sts.region,
endpoint: sts.endpoint
});
// 生成文件名
const fileName = `${generateUUID()}.${getFileExtension(file.name)}`;
const fullPath = `${sts.uploadPath}/${fileName}`;
// 上传文件
const result = await client.put(fullPath, file);
// 返回文件 URL
return result.url;
}
// 使用示例
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
const fileUrl = await uploadFile(file, 'image');
console.log('文件上传成功:', fileUrl);
});
八、总结
使用阿里云 OSS + STS 实现文件上传方案,具有以下优势:
安全性:
- ✅ 临时凭证,自动过期
- ✅ 权限最小化,精确控制
- ✅ 主账号密钥不暴露
性能:
- ✅ 客户端直传,不经过服务端
- ✅ 凭证缓存,减少 API 调用
- ✅ 响应速度快,用户体验好
可维护性:
- ✅ 路径组织清晰,便于管理
- ✅ 日志完整,便于审计
- ✅ 配置灵活,易于扩展
关键要点:
- 使用 STS 而非永久密钥:保证安全性
- 实现凭证缓存:提升性能和降低成本
- 合理组织文件路径:便于管理和权限控制
- 遵循安全最佳实践:多层防护,降低风险
希望本文能帮助大家更好地理解和实现安全的文件上传方案。在实际项目中,要根据具体业务需求调整配置,但核心安全原则是不变的。