教程:使用 TypeScript 将映像上传到 Azure 存储 Blob

本教程介绍如何在不公开凭据的情况下将文件从浏览器直接上传到 Azure Blob 存储。 你将使用 TypeScript 通过共享访问签名(SAS)令牌和托管标识来实现 Valet Key 模式 ,以实现安全、无密钥身份验证。

示例应用程序包括:

  • 一个 Fastify API,用于生成限时 SAS 令牌
  • 将文件直接上传到 Azure 存储的 React 前端
  • 使用 Azure 开发人员 CLI 进行部署的基础结构即代码

在本教程结束时,你将有一个部署到 Azure 容器应用的工作应用程序,用于演示安全文件上传,而无需向浏览器公开存储凭据。

先决条件

在开始之前,请确保具备:

小窍门

本教程使用 GitHub Codespaces,该空间在浏览器中提供预配置的开发环境。 不需要本地设置。

Architecture

显示上传流的 Azure 体系结构关系图:用户在 Web 应用前端中选择文件,前端从 API 应用后端请求 SAS 令牌,后端从托管标识获取用户委托密钥,并从存储 Blob 容器生成 SAS 令牌,前端使用 SAS 令牌将文件直接上传到存储,后端查询存储以列出上传的文件。容器注册表为这两个应用提供容器映像。

前端从 API 请求 SAS 令牌,然后将文件直接上传到 Azure 存储。 上传后,API 会列出所有上传的文件,其中包含只读 SAS 令牌以供显示。

标题为“将文件上传到 Azure 存储”的 Web 应用的屏幕截图,其中显示了“选择文件”按钮和容器名称上传。

重要概念

用户委派 SAS 令牌

应用程序使用用户委派 SAS 令牌进行安全无密钥身份验证。 这些令牌通过托管标识 (Managed Identity) 使用 Microsoft Entra ID 凭据进行签名。 该 API 生成生存期较短的令牌(10-60 分钟),具有特定权限(读取、写入或删除),从而允许浏览器将文件直接上传到存储,而无需公开凭据。

Azure 开发人员 CLI 部署

使用 azd up 部署完整的基础结构。 这会为 React 前端和 Fastify API 后端预配 Azure 容器应用,配置托管标识并分配 RBAC 权限。 基础结构使用 Bicep 模板,遵循 Azure Well-Architected 框架原则和 Azure 验证模块(如果适用)。

开发容器环境

本教程 的完整示例代码GitHub Codespaces 或本地 Visual Studio Code 中使用开发容器。

注释

还可以使用 开发容器扩展在 Visual Studio Code 中本地运行本教程。 完整的示例代码包括开发容器配置。

在 GitHub Codespaces 中打开示例

GitHub Codespaces 提供基于浏览器的 VS Code 环境,其中预安装了所有依赖项。

重要

所有 GitHub 帐户都可以将 Codespaces 与每月的免费小时一起使用。 有关详细信息,请参阅 GitHub Codespaces 每月包含的存储和核心小时数

  1. 在 Web 浏览器中,打开 示例存储库 ,并在主服务器上选择 “代码>创建代码空间”。

    GitHub 存储库页的屏幕截图,其中突出显示了“转到文件”、“添加文件和绿色代码”按钮。

  2. 等待开发容器启动。 此启动过程可能需要几分钟时间。 本教程中的其余步骤在此开发容器的上下文中进行。

部署示例

  1. 登录到 Azure。

    azd auth login
    
  2. 预配资源并将示例部署到托管环境。

    azd up
    

    出现提示时,输入以下信息:

    提示 进入
    输入唯一的环境名称 secure-upload
    选择要使用的 Azure 订阅 从列表中选择订阅
    输入“location”基础结构参数的值 从可用位置中进行选择

    或者,如果您想查看预配的资源和部署输出,可以运行以下命令来部署,从而无需提示输入:

    azd provision
    

    然后运行以下命令来部署应用程序代码:

    azd deploy
    

    如果更改 API 或 Web 应用代码,只需使用以下命令之一重新部署应用程序代码:

    azd deploy app
    azd deploy api
    
  3. 部署完成后,请注意终端中显示的已部署 Web 应用的 URL。

      (✓) Done: Deploying service app
      - Endpoint: https://app-gp2pofajnjhy6.calmtree-87e53015.eastus2.azurecontainerapps.io/
    

    这是一个示例 URL。 URL 将有所不同。

试用示例

  1. 在新浏览器选项卡中打开已部署的 Web 应用,然后选择要上传的 PNG 文件。 文件夹中提供了 ./docs/media 多个 PNG 文件。

    用于将文件上传到 Azure 存储的 Web 应用的屏幕截图,其中显示了“选择文件”按钮和容器名称上传。

  2. 选择“ 获取 SAS 令牌”,然后选择“ 上传文件”。

  3. 在“上传”按钮下方的图库中查看你上传的文件。

    将 daisies.jpg 上传到 Azure 存储后 Web 应用的屏幕截图,其中显示了文件名、SAS URL、上传状态和图像缩略图。

发生了什么?

  • 使用具有写入权限且限时有效的 SAS 令牌,将您的文件直接从浏览器上传到 Azure 存储。
  • 使用只读 SAS 令牌直接从 Azure 存储加载画廊图像
  • 浏览器中未公开身份验证机密

代码的工作原理

现在,你已了解了应用程序的实际运行情况,请探索代码如何实现安全文件上传。 应用程序有两个主要部分:

  1. API 后端 - 使用 Azure 进行身份验证并生成 SAS 令牌
  2. React 前端 - 使用 SAS 令牌将文件直接上传到 Azure 存储

以下各部分将逐步介绍关键代码实现。

用于生成 SAS 令牌和列出文件的 API 服务器

API 服务器向 Azure 存储进行身份验证,并为浏览器生成限时 SAS 令牌。

使用托管标识进行身份验证

该应用程序使用具有托管标识的用户委派密钥进行身份验证,这是 Azure 应用程序最安全的方法。 按 ChainedTokenCredential 以下顺序尝试身份验证方法:

  1. 在 Azure 中ManagedIdentityCredential (容器应用标识)
  2. 本地开发AzureCliCredential (会话 az login
// From: packages/api/src/lib/azure-storage.ts
export function getCredential(): ChainedTokenCredential {
  if (!_credential) {
    const clientId = process.env.AZURE_CLIENT_ID;
    
    // Create credential chain with ManagedIdentity first
    const credentials = [
      new ManagedIdentityCredential(clientId ? { clientId } : undefined),
      new AzureCliCredential()
    ];
    
    _credential = new ChainedTokenCredential(...credentials);
  }
  return _credential;
}

身份验证后,创建一个 BlobServiceClient 以与 Azure 存储交互。

// From: packages/api/src/lib/azure-storage.ts
export function getBlobServiceClient(accountName: string): BlobServiceClient {
  const credential = getCredential();
  const url = `https://${accountName}.blob.core.windows.net`;
  
  return new BlobServiceClient(url, credential);
}

使用用户委派密钥生成 SAS 令牌

SAS 令牌需要用户委派密钥,该密钥使用 Microsoft Entra ID 凭据(而不是存储帐户密钥)对令牌进行身份验证。 密钥在特定时间范围内有效:

const startsOn = new Date();
const expiresOn = new Date(startsOn.valueOf() + minutes * 60 * 1000);

const userDelegationKey = await blobServiceClient.getUserDelegationKey(
  startsOn,
  expiresOn
);

为文件上传生成仅写 SAS 令牌

对于文件上传,API 会生成仅写令牌,这些令牌无法读取或删除数据。 令牌在 10 分钟后过期:

// From: packages/api/src/routes/sas.ts
const DEFAULT_SAS_TOKEN_PERMISSION = 'w';
const DEFAULT_SAS_TOKEN_EXPIRATION_MINUTES = 10;

const sasToken = generateBlobSASQueryParameters(
  {
    containerName: container,
    blobName: file,
    permissions: BlobSASPermissions.parse(permission),
    startsOn,
    expiresOn
  },
  userDelegationKey,
  accountName
).toString();

const sasUrl = `${blobClient.url}?${sasToken}`;

可用权限级别:

  • 'r' - 阅读(下载/查看)
  • 'w' - 写入 (上传/覆盖) - 用于上传
  • 'd' -删除
  • 'c' -创建
  • 'a' - 添加(追加blob)

生成用于列出和查看文件的只读 SAS 令牌

对于列出和显示文件,API 将生成在 60 分钟后过期的只读令牌:

// From: packages/api/src/routes/list.ts
const LIST_SAS_TOKEN_PERMISSION = 'r';
const LIST_SAS_TOKEN_EXPIRATION_MINUTES = 60;

const sasToken = generateBlobSASQueryParameters(
  {
    containerName: container,
    blobName: blob.name,
    permissions: BlobSASPermissions.parse(LIST_SAS_TOKEN_PERMISSION),
    startsOn,
    expiresOn
  },
  userDelegationKey,
  accountName
).toString();

const sasUrl = `${blobClient.url}?${sasToken}`;

Web 应用客户端请求并从 API 服务器接收 SAS 令牌

React 前端从 API 请求 SAS 令牌,并使用它们直接从浏览器将文件上传到 Azure 存储。

前端遵循三个步骤:

  1. 从 API 请求针对特定文件的 SAS 令牌
  2. 使用 SAS 令牌 URL 直接上传到 Azure 存储
  3. 使用只读 SAS 令牌提取并显示上传的文件列表

此体系结构使后端保持轻量级 - 它只生成令牌,永远不会处理文件数据。

从 API 服务器请求 Blob 存储 SAS 令牌

当用户选择文件并单击“获取 SAS 令牌”时,前端会从 API 请求仅写 SAS 令牌:

// From: packages/app/src/App.tsx
const handleFileSasToken = () => {
  const permission = 'w'; // write-only
  const timerange = 10;   // 10 minutes expiration

  if (!selectedFile) return;

  // Build API request URL
  const url = `${API_URL}/api/sas?file=${encodeURIComponent(
    selectedFile.name
  )}&permission=${permission}&container=${containerName}&timerange=${timerange}`;

  fetch(url, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json'
    }
  })
    .then((response) => {
      if (!response.ok) {
        throw new Error(`Error: ${response.status} ${response.statusText}`);
      }
      return response.json();
    })
    .then((data: SasResponse) => {
      const { url } = data;
      setSasTokenUrl(url); // Store the SAS URL for upload
    });
};

发生的情况:

  • 前端发送: GET /api/sas?file=photo.jpg&permission=w&container=upload&timerange=10
  • API 返回: { url: "https://storageaccount.blob.core.windows.net/upload/photo.jpg?sv=2024-05-04&..." }
  • 此 URL 有效期为 10 分钟,并授予对该特定 Blob 的写入专用 访问权限

使用 SAS 令牌直接上传到 Blob 存储

收到 SAS 令牌 URL 后,前端会将该文件转换为 ArrayBuffer,并将文件 直接上传到 Azure 存储 - 完全绕过 API。 这样可减少服务器负载并提高性能。

将文件转换为 ArrayBuffer。

// From: packages/app/src/lib/convert-file-to-arraybuffer.ts
export function convertFileToArrayBuffer(file: File): Promise<ArrayBuffer | null> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = () => {
      const arrayBuffer = reader.result;
      resolve(arrayBuffer as ArrayBuffer);
    };

    reader.onerror = () => {
      reject(new Error('Error reading file.'));
    };

    reader.readAsArrayBuffer(file);
  });
}

然后,使用@azure/storage-blob中的BlockBlobClient和SAS令牌URL上传文件数据。

// From: packages/app/src/App.tsx
const handleFileUpload = () => {
  console.log('SAS Token URL:', sasTokenUrl);

  // Convert file to ArrayBuffer
  convertFileToArrayBuffer(selectedFile as File)
    .then((fileArrayBuffer) => {
      if (fileArrayBuffer === null || fileArrayBuffer.byteLength < 1) {
        throw new Error('Failed to convert file to ArrayBuffer');
      }

      // Create Azure Storage client with SAS URL
      const blockBlobClient = new BlockBlobClient(sasTokenUrl);
      
      // Upload directly to Azure Storage
      return blockBlobClient.uploadData(fileArrayBuffer);
    })
    .then((uploadResponse) => {
      if (!uploadResponse) {
        throw new Error('Upload failed - no response from Azure Storage');
      }
      setUploadStatus('Successfully finished upload');
      
      // After upload, fetch the updated list of files
      const listUrl = `${API_URL}/api/list?container=${containerName}`;
      return fetch(listUrl);
    });
};

要点

  • 文件 永远不会通过 API 服务器传递
  • 上传直接从浏览器到 Azure 存储
  • SAS 令牌对请求进行身份验证
  • 文件处理没有服务器带宽或处理成本

直接从 Azure 存储提取文件并显示缩略图

成功上传后,前端将提取容器中所有文件的列表。 列表中的每个文件都有其自己的 只读 SAS 令牌

// From: packages/app/src/App.tsx
const listUrl = `${API_URL}/api/list?container=${containerName}`;

fetch(listUrl)
  .then((response) => {
    if (!response.ok) {
      throw new Error(`Error: ${response.status}`);
    }
    return response.json();
  })
  .then((data: ListResponse) => {
    setList(data.list); // Array of SAS URLs with read permission
  });

响应示例:

{
  "list": [
    "https://storageaccount.blob.core.windows.net/upload/photo1.jpg?sv=2024-05-04&se=2025-12-18T15:30:00Z&sr=b&sp=r&...",
    "https://storageaccount.blob.core.windows.net/upload/photo2.jpg?sv=2024-05-04&se=2025-12-18T15:30:00Z&sr=b&sp=r&..."
  ]
}

前端直接在映像标记中使用 SAS URL。 浏览器使用嵌入式只读令牌从 Azure 存储中提取图像:

// From: packages/app/src/App.tsx
<Grid container spacing={2}>
  {list.map((item) => {
    const urlWithoutQuery = item.split('?')[0];
    const filename = urlWithoutQuery.split('/').pop() || '';
    const isImage = filename.endsWith('.jpg') || 
                    filename.endsWith('.png') || 
                    filename.endsWith('.jpeg');
    
    return (
      <Grid item xs={6} sm={4} md={3} key={item}>
        <Card>
          {isImage ? (
            <CardMedia component="img" image={item} alt={filename} />
          ) : (
            <Typography>{filename}</Typography>
          )}
        </Card>
      </Grid>
    );
  })}
</Grid>

工作原理

  • 列表中的每个 URL 都包含一个只读 SAS 令牌(sp=r
  • 浏览器直接向 Azure 存储发出 GET 请求
  • 无需身份验证 - 令牌位于 URL 中
  • 令牌在 60 分钟后过期(在 API 中配置)

清理资源

完成本教程后,请删除所有 Azure 资源以避免持续收费:

azd down

故障排除

在 GitHub 存储库中报告样本的问题。 请随问题一起包括以下内容:

  • 文章的 URL
  • 有问题的文章中的步骤或上下文
  • 您的开发环境

示例代码

后续步骤

了解如何安全地将文件上传到 Azure 存储后,请浏览以下相关主题: