微信小程序(H5)上传文件到阿里云 OSS(使用 STS 临时凭证)

安全最佳实践 :通过 RAM 角色 + STS 临时凭证 实现小程序或H5直传 OSS完全避免泄露长期密钥

本文档所有 敏感配置均已掩码,请勿直接使用示例值。


一、阿里云控制台配置

1. 创建自定义权限策略(最小权限)

json 复制代码
{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "oss:PutObject",
        "oss:AbortMultipartUpload"
      ],
      "Resource": [
        "acs:oss:*:*:*"
      ]
    }
  ]
}

说明:仅授权上传和取消分片上传操作,遵循最小权限原则。


2. 创建 RAM 角色并授权

步骤:
  1. 创建角色

    • 类型:阿里云服务
    • 受信服务:Object Storage Service (OSS)
  2. 附加策略

    • 选择步骤 1 创建的自定义策略
  3. 编辑信任策略(关键!控制谁可以扮演该角色)

json 复制代码
{
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Effect": "Allow",
      "Principal": {
        "RAM": [
          "acs:ram:<YOUR_ACCOUNT_ID>:root"
        ],
        "Service": [
          "oss.aliyuncs.com"
        ]
      }
    }
  ],
  "Version": "1"
}

掩码说明<YOUR_ACCOUNT_ID> → 替换为你的 阿里云主账号 ID (如 188123123xxxxxxx


二、后端获取 STS 临时凭证(多语言实现)

统一返回格式(供前端使用):

json 复制代码
{
  "accessKeyId": "STS.xxxx",
  "securityToken": "xxxx",
  "policy": "eyJl....",
  "signature": "xxxx",
  "bucket": "<YOUR_BUCKET>",
  "host": "https://<bucket>.oss-cn-beijing.aliyuncs.com",
  "dir": "upload_txt/",
  "expiration": "2025-10-30T12:00:00Z"
}

1. Java(完整实现)

java 复制代码
package pattern.config;

import com.alibaba.fastjson.JSON;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.auth.sts.AssumeRoleRequest;
import com.aliyuncs.auth.sts.AssumeRoleResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.http.ProtocolType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import lombok.extern.slf4j.Slf4j;
import pattern.exception.PatternException;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

/**
 * @author Simon
 * @date 2025/10/30
 */
@Slf4j
public class AliyunOssObdTool {

    // 关键配置(生产环境请使用配置中心或环境变量)
    private static final String ACCESS_KEY_ID      = "**<YOUR_ACCESS_KEY_ID>**";
    private static final String ACCESS_KEY_SECRET  = "**<YOUR_ACCESS_KEY_SECRET>**";
    private static final String ROLE_ARN           = "acs:ram::**<YOUR_ACCOUNT_ID>**:role/**<ROLE_NAME>**";
    private static final String ROLE_SESSION_NAME  = "sts-upload";
    private static final String STS_API_VERSION    = "2015-04-01";
    private static final String REGION_ID          = "cn-beijing";
    private static final String OSS_REGION_ID      = "oss-cn-beijing"; //需要具体的区域
    private static final String BUCKET             = "**<YOUR_BUCKET_NAME>**";
    private static final String DIR                = "upload_txt/";
    private static final Long   DURATION_SECONDS    = 1800L; // 30 分钟

    public static Map<String, Object> assumeRole() {
        try {
            // 1. 初始化 STS 客户端
            IClientProfile profile = DefaultProfile.getProfile(REGION_ID, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
            DefaultAcsClient client = new DefaultAcsClient(profile);

            // 2. 构造 AssumeRole 请求
            AssumeRoleRequest request = new AssumeRoleRequest();
            request.setVersion(STS_API_VERSION);
            request.setSysMethod(MethodType.POST);
            request.setRoleArn(ROLE_ARN);
            request.setSysProtocol(ProtocolType.HTTPS);
            request.setRoleSessionName(ROLE_SESSION_NAME);
            request.setDurationSeconds(DURATION_SECONDS);

            // 3. 获取临时凭证
            AssumeRoleResponse stsToken = client.getAcsResponse(request);

            // 4. 构造 Policy(限制上传路径、大小)
            long expireTime = Instant.now().plusSeconds(1800).toEpochMilli();
            String expiration = Instant.ofEpochMilli(expireTime)
                    .atOffset(ZoneOffset.UTC)
                    .toString();

            String policyStr = String.format(
                "{\"expiration\":\"%s\",\"conditions\":[[\"content-length-range\",0,1048576],[\"starts-with\",\"$key\",\"%s\"],{\"bucket\":\"%s\"}]}",
                expiration, DIR, BUCKET
            );
            String policyBase64 = Base64.getEncoder().encodeToString(policyStr.getBytes(StandardCharsets.UTF_8));

            // 5. 计算签名
            String signature = calculateSignature(stsToken.getCredentials().getAccessKeySecret(), policyBase64);

            // 6. 返回前端所需参数
            Map<String, Object> result = new HashMap<>();
            result.put("accessKeyId", stsToken.getCredentials().getAccessKeyId());
            result.put("securityToken", stsToken.getCredentials().getSecurityToken());
            result.put("policy", policyBase64);
            result.put("signature", signature);
            result.put("bucket", BUCKET);
            result.put("host", "https://" + BUCKET + "." + OSS_REGION_ID + ".aliyuncs.com");
            result.put("dir", DIR);
            result.put("expiration", expiration);
            return result;

        } catch (ClientException e) {
            log.error("获取sts token error", e);
            throw new PatternException("获取sts token error");
        }
    }

    private static String calculateSignature(String accessKeySecret, String policyBase64) {
        try {
            Mac mac = Mac.getInstance("HmacSHA1");
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"
            );
            mac.init(secretKeySpec);
            byte[] hash = mac.doFinal(policyBase64.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (Exception e) {
            throw new RuntimeException("计算 signature 失败", e);
        }
    }
}

2. Node.js(Express 示例)

js 复制代码
const STS = require('aliyun-sdk').STS;
const crypto = require('crypto');
const express = require('express');
const app = express();

// 掩码配置
const config = {
  accessKeyId: '**<YOUR_ACCESS_KEY_ID>**',
  accessKeySecret: '**<YOUR_ACCESS_KEY_SECRET>**',
  roleArn: 'acs:ram::**<YOUR_ACCOUNT_ID>**:role/**<ROLE_NAME>**',
  bucket: '**<YOUR_BUCKET_NAME>**',
  dir: 'upload_txt/',
  region: 'oss-cn-beijing',
  durationSeconds: 1800
};

const sts = new STS({
  accessKeyId: config.accessKeyId,
  accessKeySecret: config.accessKeySecret
});

app.get('/sts', async (req, res) => {
  try {
    const result = await sts.assumeRole({
      RoleArn: config.roleArn,
      RoleSessionName: 'sts-upload',
      DurationSeconds: config.durationSeconds
    }).promise();

    const credentials = result.Credentials;
    const expiration = new Date(Date.now() + config.durationSeconds * 1000).toISOString();

    const policyStr = JSON.stringify({
      expiration,
      conditions: [
        ['content-length-range', 0, 1048576],
        ['starts-with', '$key', config.dir],
        { bucket: config.bucket }
      ]
    });

    const policyBase64 = Buffer.from(policyStr).toString('base64');
    const signature = crypto.createHmac('sha1', credentials.AccessKeySecret)
                           .update(policyBase64)
                           .digest('base64');

    res.json({
      accessKeyId: credentials.AccessKeyId,
      securityToken: credentials.SecurityToken,
      policy: policyBase64,
      signature,
      bucket: config.bucket,
      host: `https://${config.bucket}.${config.region}.aliyuncs.com`,
      dir: config.dir,
      expiration
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(3000, () => console.log('STS Server running on port 3000'));

依赖npm install aliyun-sdk express


3. Python(Flask 示例)

python 复制代码
from aliyunsdkcore.client import AcsClient
from aliyunsdksts.request.v20150401 import AssumeRoleRequest
import json
import base64
import hmac
import hashlib
from datetime import datetime, timedelta
from flask import Flask, jsonify

app = Flask(__name__)

# 掩码配置
config = {
    'access_key_id': '**<YOUR_ACCESS_KEY_ID>**',
    'access_key_secret': '**<YOUR_ACCESS_KEY_SECRET>**',
    'role_arn': 'acs:ram::**<YOUR_ACCOUNT_ID>**:role/**<ROLE_NAME>**',
    'bucket': '**<YOUR_BUCKET_NAME>**',
    'dir': 'upload_txt/',
    'region': 'cn-beijing',
    'duration_seconds': 1800
}

client = AcsClient(config['access_key_id'], config['access_key_secret'], config['region'])

@app.route('/sts')
def get_sts():
    try:
        request = AssumeRoleRequest.AssumeRoleRequest()
        request.set_RoleArn(config['role_arn'])
        request.set_RoleSessionName('wx-upload')
        request.set_DurationSeconds(config['duration_seconds'])

        response = client.do_action_with_exception(request)
        result = json.loads(response.decode())

        credentials = result['Credentials']
        expiration = (datetime.utcnow() + timedelta(seconds=config['duration_seconds'])).isoformat() + 'Z'

        policy = {
            "expiration": expiration,
            "conditions": [
                ["content-length-range", 0, 1048576],
                ["starts-with", "$key", config['dir']],
                {"bucket": config['bucket']}
            ]
        }

        policy_str = json.dumps(policy)
        policy_base64 = base64.b64encode(policy_str.encode()).decode()
        signature = base64.b64encode(
            hmac.new(
                credentials['AccessKeySecret'].encode(),
                policy_base64.encode(),
                hashlib.sha1
            ).digest()
        ).decode()

        return jsonify({
            'accessKeyId': credentials['AccessKeyId'],
            'securityToken': credentials['SecurityToken'],
            'policy': policy_base64,
            'signature': signature,
            'bucket': config['bucket'],
            'host': f"https://{config['bucket']}.oss-{config['region']}.aliyuncs.com",
            'dir': config['dir'],
            'expiration': expiration
        })
    except Exception as e:
        return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
    app.run(port=5000)

依赖pip install aliyun-python-sdk-core aliyun-python-sdk-sts flask


4. Go(Gin 示例)

go 复制代码
package main

import (
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base64"
	"encoding/json"
	"net/http"
	"time"

	"github.com/aliyun/alibabacloud-sdk-go/services/sts"
	"github.com/gin-gonic/gin"
)

type Config struct {
	AccessKeyID     string `json:"access_key_id"`
	AccessKeySecret string `json:"access_key_secret"`
	RoleArn         string `json:"role_arn"`
	Bucket          string `json:"bucket"`
	Dir             string `json:"dir"`
	Region          string `json:"region"`
	DurationSeconds int64  `json:"duration_seconds"`
}

var config = Config{
	AccessKeyID:     "**<YOUR_ACCESS_KEY_ID>**",
	AccessKeySecret: "**<YOUR_ACCESS_KEY_SECRET>**",
	RoleArn:         "acs:ram::**<YOUR_ACCOUNT_ID>**:role/**<ROLE_NAME>**",
	Bucket:          "**<YOUR_BUCKET_NAME>**",
	Dir:             "upload_txt/",
	Region:          "cn-beijing",
	DurationSeconds: 1800,
}

func getSTS(c *gin.Context) {
	client, _ := sts.NewClientWithAccessKey(config.Region, config.AccessKeyID, config.AccessKeySecret)

	request := sts.CreateAssumeRoleRequest()
	request.RoleArn = config.RoleArn
	request.RoleSessionName = "sts-upload"
	request.DurationSeconds = config.DurationSeconds

	response, err := client.AssumeRole(request)
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}

	expiration := time.Now().Add(time.Duration(config.DurationSeconds) * time.Second).UTC().Format("2006-01-02T15:04:05Z")

	policy := map[string]interface{}{
		"expiration": expiration,
		"conditions": []interface{}{
			[]interface{}{"content-length-range", 0, 1048576},
			[]interface{}{"starts-with", "$key", config.Dir},
			map[string]string{"bucket": config.Bucket},
		},
	}

	policyBytes, _ := json.Marshal(policy)
	policyBase64 := base64.StdEncoding.EncodeToString(policyBytes)

	mac := hmac.New(sha1.New, []byte(response.Credentials.AccessKeySecret))
	mac.Write([]byte(policyBase64))
	signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	c.JSON(200, gin.H{
		"accessKeyId":    response.Credentials.AccessKeyId,
		"securityToken":  response.Credentials.SecurityToken,
		"policy":         policyBase64,
		"signature":      signature,
		"bucket":         config.Bucket,
		"host":           "https://" + config.Bucket + ".oss-" + config.Region + ".aliyuncs.com",
		"dir":            config.Dir,
		"expiration":     expiration,
	})
}

func main() {
	r := gin.Default()
	r.GET("/sts", getSTS)
	r.Run(":8080")
}

依赖go get github.com/aliyun/alibabacloud-sdk-go/services/sts github.com/gin-gonic/gin


三、前端微信小程序上传逻辑(通用)

js 复制代码
let cachedSts = null;
const API_STS_TOKEN = '**<YOUR_STS_API_URL>**'; // 例如: https://yourdomain.com/sts

function isExpired(expiration) {
  if (!expiration) return true;
  const expireTime = new Date(expiration).getTime();
  return Date.now() >= expireTime - 60 * 1000; // 提前1分钟刷新
}

async function getUploadParams() {
  if (cachedSts && !isExpired(cachedSts.expiration)) {
    console.log('使用缓存的上传参数');
    return cachedSts;
  }

  return new Promise((resolve, reject) => {
    wx.request({
      url: API_STS_TOKEN,
      method: 'GET',
      success: (res) => {
        if (res.statusCode === 200) {
          cachedSts = res.data;
          console.log('获取新上传参数');
          resolve(cachedSts);
        } else {
          reject(new Error('获取上传参数失败'));
        }
      },
      fail: (err) => reject(new Error('网络错误: ' + err.errMsg))
    });
  });
}

function getFileNameDateTime() {
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  const hours = String(now.getHours()).padStart(2, '0');
  const minutes = String(now.getMinutes()).padStart(2, '0');
  const seconds = String(now.getSeconds()).padStart(2, '0');
  const random = Math.random().toString(36).substring(2, 4);
  return {
    year,
    month,
    fileName: `${day}_${hours}${minutes}_${seconds}${random}.txt`
  };
}

function writeStringToTempFile(content, filename) {
  return new Promise((resolve, reject) => {
    const fs = wx.getFileSystemManager();
    const tempPath = `${wx.env.USER_DATA_PATH}/${filename}`;
    fs.writeFile({
      filePath: tempPath,
      data: content,
      encoding: 'utf8',
      success: () => resolve(tempPath),
      fail: reject
    });
  });
}

export async function uploadStringToOss(content) {
  const params = await getUploadParams();
  const { accessKeyId, securityToken, policy, signature, bucket, host, dir } = params;

  const { fileName, year, month } = getFileNameDateTime();
  const key = `${dir}wx_log/${year}${month}/${fileName}`;

  const tempFilePath = await writeStringToTempFile(content, fileName);

  const formData = {
    key,
    OSSAccessKeyId: accessKeyId,
    policy,
    success_action_status: '204',
    signature,
    'x-oss-security-token': securityToken
  };

  return new Promise((resolve, reject) => {
    wx.uploadFile({
      url: host,
      name: 'file',
      filePath: tempFilePath,
      formData,
      header: {
        'x-oss-security-token': securityToken
      },
      success: (res) => {
        if (res.statusCode === 204) {
          const fileUrl = `${host}/${key}`;
          console.log('上传成功:', fileUrl);
          resolve(fileUrl);
        } else {
          reject(new Error(`OSS 上传失败: ${res.statusCode}`));
        }
      },
      fail: reject
    });
  });
}

四、H5 页面上传(完整)

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>H5 上传到 OSS</title>
</head>
<body>
  <input type="file" id="fileInput" />
  <button onclick="upload()">上传</button>
  <div id="result"></div>

  <script>
    const API_STS_TOKEN = '**<YOUR_STS_API_URL>**'; // 与小程序共用

    let cachedSts = null;

    async function getUploadParams() {
      if (cachedSts && !isExpired(cachedSts.expiration)) {
        return cachedSts;
      }
      const res = await fetch(API_STS_TOKEN);
      const data = await res.json();
      cachedSts = data.data || data;
      return cachedSts;
    }

    function isExpired(expiration) {
      return Date.now() >= new Date(expiration).getTime() - 60000;
    }

    function getFileNameDateTime() {
      const now = new Date();
      const year = now.getFullYear();
      const month = String(now.getMonth() + 1).padStart(2, '0');
      const day = String(now.getDate()).padStart(2, '0');
      const hours = String(now.getHours()).padStart(2, '0');
      const minutes = String(now.getMinutes()).padStart(2, '0');
      const seconds = String(now.getSeconds()).padStart(2, '0');
      const random = Math.random().toString(36).substring(2, 4);
      return { year, month, fileName: `${day}_${hours}${minutes}_${seconds}${random}.txt` };
    }

    async function upload() {
      const fileInput = document.getElementById('fileInput');
      const file = fileInput.files[0];
      if (!file) return alert('请选择文件');

      try {
        const params = await getUploadParams();
        const { accessKeyId, securityToken, policy, signature, bucket, host, dir } = params;

        const { year, month, fileName } = getFileNameDateTime();
        const key = `${dir}h5_log/${year}${month}/${fileName}`;

        const formData = new FormData();
        formData.append('key', key);
        formData.append('OSSAccessKeyId', accessKeyId);
        formData.append('policy', policy);
        formData.append('signature', signature);
        formData.append('success_action_status', '204');
        formData.append('x-oss-security-token', securityToken);
        formData.append('file', file);

        const xhr = new XMLHttpRequest();
        xhr.open('POST', host, true);

        xhr.onload = function () {
          if (xhr.status === 204) {
            const fileUrl = `${host}/${key}`;
            document.getElementById('result').innerHTML = `上传成功: <a href="${fileUrl}" target="_blank">${fileUrl}</a>`;
          } else {
            alert('上传失败: ' + xhr.status);
          }
        };

        xhr.onerror = function () {
          alert('网络错误');
        };

        xhr.send(formData);
      } catch (err) {
        alert('上传异常: ' + err.message);
      }
    }
  </script>
</body>
</html>

五、安全总结与建议

项目 建议
密钥管理 后端使用环境变量 / 配置中心
STS 有效期 建议 900~1800 秒
Policy 限制 必须限制 bucketkey 前缀、content-length
CORS 配置 OSS Bucket 开启 CORS,允许 PUT 和必要头
成功状态码 使用 204 No Content 避免返回 XML

六、完整流程图


更新时间 :2025-10-30
作者:Simon(优化整理)

相关推荐
李慕婉学姐11 小时前
【开题答辩过程】以《“饭否”食材搭配指南小程序的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·spring·小程序
00后程序员张14 小时前
Swift 应用加密工具的全面方案,从源码混淆到 IPA 成品加固的多层安全实践
安全·ios·小程序·uni-app·ssh·iphone·swift
低代码布道师16 小时前
医疗小程序11获取最近7天排班计划
低代码·小程序
2501_9160088917 小时前
提高 iOS 应用逆向难度的工程实践,多工具联动的全栈安全方案
android·安全·ios·小程序·uni-app·cocoa·iphone
云起SAAS17 小时前
早晚安打卡抖音快手微信小程序看广告流量主开源
微信小程序·小程序·ai编程·看广告变现轻·早晚安打卡
万岳科技系统开发18 小时前
教育系统小程序的架构设计:如何构建高效稳定的教育平台?
小程序
翼龙云_cloud18 小时前
阿里云渠道商:如何在NAS控制台创建通用型NAS NFS协议文件系统?
运维·服务器·阿里云·云计算
HUT_Tyne26518 小时前
阿里云ACP认证
阿里云·云计算·php
熙客18 小时前
阿里云性能测试:PTS的使用
阿里云·云计算
gongzemin18 小时前
约课小程序增加候补功能
前端·微信小程序·小程序·云开发