Skip to content

预签名 URL

预签名 URL (Presigned URL) 是一种临时授权访问 S3 对象的机制,无需公开 Bucket 或对象权限。

🔑 什么是预签名 URL

预签名 URL 包含临时签名信息,允许在指定时间内访问私有对象。

常规 URL:
https://my-bucket.s3.amazonaws.com/photo.jpg  ❌ 403 Forbidden

预签名 URL:
https://my-bucket.s3.amazonaws.com/photo.jpg?
  AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&
  Expires=1234567890&
  Signature=signature_string               ✅ 200 OK

🎯 使用场景

1. 临时下载权限

场景: 用户购买数字产品
└─> 生成临时下载链接
    └─> 链接1小时后失效

2. 安全文件上传

场景: 用户上传头像
└─> 前端获取预签名 URL
    └─> 直接上传到 S3
        └─> 不经过后端服务器

3. 分享私有文件

场景: 分享私密文档给特定用户
└─> 生成限时链接
    └─> 无需修改权限

⬇️ 下载对象的预签名 URL

Node.js

javascript
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

// 生成下载 URL (GET)
const getPresignedUrl = (bucketName, key, expiresIn = 3600) => {
  const params = {
    Bucket: bucketName,
    Key: key,
    Expires: expiresIn  // 秒,默认 3600 (1小时)
  };
  
  const url = s3.getSignedUrl('getObject', params);
  return url;
};

// 使用示例
const url = getPresignedUrl('my-bucket', 'document.pdf', 3600);
console.log('Download URL:', url);

// 浏览器中直接访问这个 URL 即可下载
// <a href="${url}" download>Download</a>

// 使用 Promise
const getPresignedUrlAsync = (bucketName, key, expiresIn = 3600) => {
  return new Promise((resolve, reject) => {
    s3.getSignedUrl('getObject', {
      Bucket: bucketName,
      Key: key,
      Expires: expiresIn
    }, (err, url) => {
      if (err) reject(err);
      else resolve(url);
    });
  });
};

Express API 示例

javascript
const express = require('express');
const app = express();

// 生成下载链接的 API
app.get('/api/files/:fileId/download-url', async (req, res) => {
  const { fileId } = req.params;
  
  // 从数据库获取文件信息
  const file = await db.files.findOne({ id: fileId, userId: req.user.id });
  
  if (!file) {
    return res.status(404).json({ error: 'File not found' });
  }
  
  // 生成预签名 URL
  const url = getPresignedUrl('my-bucket', file.s3Key, 3600);
  
  res.json({
    url: url,
    expiresIn: 3600,
    expiresAt: new Date(Date.now() + 3600 * 1000)
  });
});

app.listen(3000);

Python

python
import boto3
from botocore.exceptions import ClientError
from datetime import datetime, timedelta

s3 = boto3.client('s3')

def generate_presigned_url(bucket_name, object_key, expiration=3600):
    """生成下载 URL"""
    try:
        url = s3.generate_presigned_url(
            'get_object',
            Params={
                'Bucket': bucket_name,
                'Key': object_key
            },
            ExpiresIn=expiration
        )
        return url
    except ClientError as e:
        print(f"Error: {e}")
        return None

# 使用示例
url = generate_presigned_url('my-bucket', 'document.pdf', 3600)
print(f"Download URL: {url}")

设置下载文件名

javascript
const getPresignedUrlWithFilename = (bucketName, key, filename, expiresIn = 3600) => {
  const params = {
    Bucket: bucketName,
    Key: key,
    Expires: expiresIn,
    ResponseContentDisposition: `attachment; filename="${filename}"`
  };
  
  return s3.getSignedUrl('getObject', params);
};

// 下载时文件名显示为 "我的文档.pdf"
const url = getPresignedUrlWithFilename(
  'my-bucket',
  'documents/abc123.pdf',
  '我的文档.pdf'
);

⬆️ 上传对象的预签名 URL

后端生成上传 URL

javascript
// 生成上传 URL (PUT)
const getUploadPresignedUrl = (bucketName, key, contentType, expiresIn = 300) => {
  const params = {
    Bucket: bucketName,
    Key: key,
    Expires: expiresIn,  // 上传链接一般设置较短时间
    ContentType: contentType
  };
  
  const url = s3.getSignedUrl('putObject', params);
  return url;
};

// Express API
app.post('/api/upload/presigned-url', async (req, res) => {
  const { filename, contentType } = req.body;
  const userId = req.user.id;
  
  // 生成唯一的 S3 key
  const key = `uploads/${userId}/${Date.now()}-${filename}`;
  
  // 生成上传 URL
  const url = getUploadPresignedUrl('my-bucket', key, contentType, 300);
  
  // 保存到数据库
  await db.files.create({
    userId: userId,
    filename: filename,
    s3Key: key,
    status: 'pending'
  });
  
  res.json({
    uploadUrl: url,
    key: key,
    expiresIn: 300
  });
});

前端直传实现

javascript
// React 示例
async function uploadFile(file) {
  try {
    // 1. 获取预签名 URL
    const response = await fetch('/api/upload/presigned-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filename: file.name,
        contentType: file.type
      })
    });
    
    const { uploadUrl, key } = await response.json();
    
    // 2. 使用预签名 URL 上传到 S3
    await fetch(uploadUrl, {
      method: 'PUT',
      body: file,
      headers: {
        'Content-Type': file.type
      }
    });
    
    // 3. 通知后端上传完成
    await fetch('/api/upload/complete', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ key })
    });
    
    console.log('Upload successful!');
  } catch (err) {
    console.error('Upload failed:', err);
  }
}

// 使用示例
<input type="file" onChange={(e) => uploadFile(e.target.files[0])} />

带进度的上传

javascript
async function uploadFileWithProgress(file, onProgress) {
  // 1. 获取预签名 URL
  const response = await fetch('/api/upload/presigned-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type
    })
  });
  
  const { uploadUrl } = await response.json();
  
  // 2. 使用 XMLHttpRequest 上传以支持进度
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const percentage = (e.loaded / e.total) * 100;
        onProgress(percentage);
      }
    });
    
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        resolve();
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });
    
    xhr.addEventListener('error', () => {
      reject(new Error('Upload failed'));
    });
    
    xhr.open('PUT', uploadUrl);
    xhr.setRequestHeader('Content-Type', file.type);
    xhr.send(file);
  });
}

// 使用
uploadFileWithProgress(file, (progress) => {
  console.log(`Upload progress: ${progress.toFixed(2)}%`);
});

📝 POST 表单上传

使用 POST 方法和表单上传,支持额外的条件。

javascript
// 后端生成 POST 表单数据
const getPostPresignedUrl = (bucketName, key, fields = {}) => {
  return new Promise((resolve, reject) => {
    const params = {
      Bucket: bucketName,
      Fields: {
        key: key,
        ...fields
      },
      Expires: 300,
      Conditions: [
        ['content-length-range', 0, 10485760],  // 最大 10MB
        ['starts-with', '$Content-Type', 'image/']  // 只允许图片
      ]
    };
    
    s3.createPresignedPost(params, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
};

// Express API
app.post('/api/upload/post-presigned', async (req, res) => {
  const { filename } = req.body;
  const key = `uploads/${Date.now()}-${filename}`;
  
  const data = await getPostPresignedUrl('my-bucket', key);
  
  res.json({
    url: data.url,
    fields: data.fields
  });
});

前端表单上传

javascript
async function uploadWithForm(file) {
  // 1. 获取表单数据
  const response = await fetch('/api/upload/post-presigned', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ filename: file.name })
  });
  
  const { url, fields } = await response.json();
  
  // 2. 构造表单
  const formData = new FormData();
  
  // 添加字段(顺序很重要,file 必须最后)
  Object.entries(fields).forEach(([key, value]) => {
    formData.append(key, value);
  });
  formData.append('file', file);
  
  // 3. 上传
  await fetch(url, {
    method: 'POST',
    body: formData
  });
  
  console.log('Upload successful!');
}

⏰ 设置过期时间

javascript
// 不同场景使用不同的过期时间

// 临时下载链接 - 5分钟
const shortUrl = getPresignedUrl('my-bucket', 'temp.pdf', 300);

// 文件分享链接 - 1小时
const shareUrl = getPresignedUrl('my-bucket', 'shared.pdf', 3600);

// 付费内容下载 - 24小时
const paidUrl = getPresignedUrl('my-bucket', 'course.mp4', 86400);

// 最大 7 天
const maxUrl = getPresignedUrl('my-bucket', 'archive.zip', 604800);

🔒 安全性增强

1. 限制 IP 地址

javascript
// 使用 Bucket Policy 限制 IP
// 预签名 URL 会继承 Bucket Policy

2. 添加自定义条件

javascript
const getSecurePresignedUrl = (bucketName, key, userId) => {
  const params = {
    Bucket: bucketName,
    Key: key,
    Expires: 3600,
    // 添加自定义元数据条件
    Metadata: {
      'x-amz-meta-user-id': userId
    }
  };
  
  return s3.getSignedUrl('getObject', params);
};

3. 单次使用链接

javascript
// 实现单次使用链接
const generateOneTimeUrl = async (bucketName, key, userId) => {
  const token = crypto.randomBytes(32).toString('hex');
  
  // 存储 token
  await db.tokens.create({
    token: token,
    userId: userId,
    s3Key: key,
    used: false,
    expiresAt: new Date(Date.now() + 3600000)
  });
  
  return `/api/download/${token}`;
};

// API 端点
app.get('/api/download/:token', async (req, res) => {
  const tokenRecord = await db.tokens.findOne({
    token: req.params.token,
    used: false,
    expiresAt: { $gt: new Date() }
  });
  
  if (!tokenRecord) {
    return res.status(404).json({ error: 'Invalid or expired token' });
  }
  
  // 标记为已使用
  await db.tokens.updateOne(
    { _id: tokenRecord._id },
    { $set: { used: true } }
  );
  
  // 生成预签名 URL 并重定向
  const url = getPresignedUrl('my-bucket', tokenRecord.s3Key, 60);
  res.redirect(url);
});

💡 最佳实践

1. 设置合理的过期时间

javascript
// ✅ 好:根据场景设置
const uploadUrl = getUploadPresignedUrl(bucket, key, contentType, 300);  // 5分钟
const downloadUrl = getPresignedUrl(bucket, key, 3600);  // 1小时

// ❌ 坏:过长的过期时间
const url = getPresignedUrl(bucket, key, 604800);  // 7天,安全风险

2. 验证文件类型

javascript
// 上传时验证 Content-Type
app.post('/api/upload/presigned-url', (req, res) => {
  const { contentType } = req.body;
  
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
  if (!allowedTypes.includes(contentType)) {
    return res.status(400).json({ error: 'Invalid file type' });
  }
  
  // 生成 URL...
});

3. 限制文件大小

javascript
const getUploadPresignedUrlWithLimit = (bucketName, key, maxSize = 5242880) => {
  return new Promise((resolve, reject) => {
    const params = {
      Bucket: bucketName,
      Fields: { key },
      Expires: 300,
      Conditions: [
        ['content-length-range', 0, maxSize]  // 最大 5MB
      ]
    };
    
    s3.createPresignedPost(params, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
};

4. 记录访问日志

javascript
app.post('/api/files/:fileId/download-url', async (req, res) => {
  const { fileId } = req.params;
  
  // 生成 URL
  const url = getPresignedUrl('my-bucket', key, 3600);
  
  // 记录访问日志
  await db.accessLogs.create({
    fileId: fileId,
    userId: req.user.id,
    action: 'download_url_generated',
    ip: req.ip,
    userAgent: req.get('user-agent'),
    timestamp: new Date()
  });
  
  res.json({ url });
});

📖 相关资源


下一步:学习 生命周期管理 ♻️