阿里云 OSS + STS:安全的文件上传方案

阿里云 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. 临时性:凭证有效期短(通常 1 小时),过期自动失效
  2. 权限最小化:可以精确控制允许的操作和资源范围
  3. 安全性高:即使泄露,影响范围和时间都有限
  4. 性能好:客户端直接上传到 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 角色配置(在阿里云控制台)

  1. 创建 RAM 角色
  • 角色名称:oss-upload-role
  • 信任实体:当前账号
  1. 配置角色权限策略
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:限制只能操作指定路径下的文件
  1. 获取角色 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 路径设计原则

  1. 按类型分类
  • 不同类型的文件存储在不同目录
  • 便于管理和权限控制
  1. 按时间分层
  • 使用日期组织文件
  • 便于清理和归档
  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 路径设计优势

  1. 便于管理
  • 按类型分类,快速定位文件类型
  • 按日期分层,便于按时间范围清理
  1. 权限控制
  • 可以为不同目录设置不同的访问权限
  • 例如:图片公开访问,文件需要认证
  1. 性能优化
  • 文件分散在不同目录,避免单目录文件过多
  • OSS 单目录文件过多会影响性能
  1. 成本控制
  • 可以按目录设置生命周期规则
  • 例如: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. 临时凭证有效期限制
  • 凭证有效期 1 小时,过期自动失效
  • 即使泄露,影响时间有限
  1. 权限最小化原则
java 复制代码
{
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["oss:PutObject"],  // 只允许上传
      "Resource": ["acs:oss:*:*:bucket/image/*"]  // 只能上传到指定目录
    }
  ]
}
  • 只授予必要的权限(上传)
  • 限制资源范围(指定目录)
  • 禁止删除、修改等危险操作
  1. 路径限制
  • 服务端控制上传路径
  • 客户端无法修改路径,防止目录遍历攻击
  • 路径包含日期,便于审计和追踪
  1. 文件类型验证(可选)
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 防止常见攻击

  1. 目录遍历攻击

    ❌ 危险路径:../../../etc/passwd
    ✅ 安全路径:image/2024/01/15/uuid.jpg

  • 服务端控制路径,客户端无法构造危险路径
  1. 文件覆盖攻击

    ✅ 使用 UUID 作为文件名,避免冲突
    ✅ 路径包含时间,进一步降低冲突概率

  2. 大文件攻击

    // 可以在 RAM 策略中限制文件大小
    {
    "Condition": {
    "NumericLessThan": {
    "oss:ContentLength": 10485760 // 限制 10MB
    }
    }
    }

  3. 恶意文件上传

  • 客户端上传后,服务端可以异步扫描文件
  • 发现恶意文件可以及时删除
  • 使用 OSS 的病毒扫描功能

6.3 安全最佳实践

  1. 主账号密钥保护
  • 主账号 AccessKey 只存储在服务端
  • 使用环境变量或密钥管理服务(如阿里云 KMS)
  • 定期轮换密钥
  1. RAM 角色权限最小化
  • 只授予必要的权限
  • 定期审查和更新权限策略
  • 使用条件限制(如 IP、时间等)
  1. 监控和告警
  • 监控 STS API 调用频率
  • 监控异常上传行为
  • 设置告警规则
  1. 日志记录
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 而非永久密钥:保证安全性
  • 实现凭证缓存:提升性能和降低成本
  • 合理组织文件路径:便于管理和权限控制
  • 遵循安全最佳实践:多层防护,降低风险

希望本文能帮助大家更好地理解和实现安全的文件上传方案。在实际项目中,要根据具体业务需求调整配置,但核心安全原则是不变的。

相关推荐
NAGNIP10 小时前
一文搞懂深度学习中的通用逼近定理!
人工智能·算法·面试
冬奇Lab11 小时前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
冬奇Lab11 小时前
OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"
人工智能·开源·源码阅读
AngelPP15 小时前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年15 小时前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼15 小时前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS16 小时前
Kimi Chat Completion API 申请及使用
前端·人工智能
天翼云开发者社区17 小时前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈17 小时前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能
Ray Liang17 小时前
被低估的量化版模型,小身材也能干大事
人工智能·ai·ai助手·mindx