阿里云 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 而非永久密钥:保证安全性
  • 实现凭证缓存:提升性能和降低成本
  • 合理组织文件路径:便于管理和权限控制
  • 遵循安全最佳实践:多层防护,降低风险

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

相关推荐
福大大架构师每日一题2 小时前
dify 1.11.2 正式发布:向量数据库、安全增强、测试优化与多语言支持全面升级
数据库·安全
程途拾光1582 小时前
自监督学习在无标签数据中的潜力释放
人工智能·学习
墨染天姬2 小时前
【AI】5w/1h分析法
人工智能
Blossom.1182 小时前
多模态大模型LoRA微调实战:从零构建企业级图文检索系统
人工智能·python·深度学习·学习·react.js·django·transformer
檐下翻书1732 小时前
模型蒸馏与压缩技术的新进展
人工智能
小趴菜不能喝2 小时前
Docker 网络
网络·docker·容器
小陈phd2 小时前
Dify从入门到精通(一)——Dify环境搭建
人工智能
zabr2 小时前
前端已死?我用 Trae + Gemini 零代码手搓 3D 塔罗牌,找到了新出路
前端·人工智能·aigc
速易达网络2 小时前
Trae智能体SOLO中国版
人工智能·trae