搭建Web应用文件直传服务

通过浏览器环境访问IMM与服务端环境访问相比,存在更高的安全风险,尤其是在处理长期和固定的访问密钥时。为降低密钥泄露的风险,您可以使用阿里云提供的安全令牌服务(STS)为浏览器端发放具有时间限制和最小必要权限的临时身份凭证,从而避免将长期的静态访问密钥暴露在较不安全的浏览器环境中。

背景信息

STS解决的一个核心问题是如何在不暴露阿里云账号的AccessKey的情况下安全地授权其他人访问。因为阿里云账号的AccessKey泄露会带来极大的安全风险,其他人可以随意操作该账号下所有的资源、盗取重要信息等。为了更好地服务用户并提高应用程序的性能和稳定性,企业A的技术团队决定实现Web应用的终端用户在浏览器直接上传文件到OSS并与IMM交互,以满足并发大规模数据处理的需求。

安全风险

为了减少企业的业务服务器负担并提高效率,该Web应用被设计为在用户的网页浏览器中直接与IMM交互,而非所有请求都通过企业的业务服务器中转。

企业A计划采取以下方案搭建Web应用文件直传服务:

image

由于网页浏览器运行环境完全处于用户端且不受企业A直接控制,企业A面临以下挑战:

  • 密钥泄露:考虑到网页浏览器归用户掌控且为不可信环境,RAM用户的访问密钥等敏感信息存储在浏览器中,面临较高的泄露风险。

  • 权限过大:当创建RAM用户作为业务服务器的应用程序的身份时,该用户通常需要较高权限以访问其他云服务。如果RAM用户的访问密钥存储在浏览器中并被泄露,可能导致权限被滥用,增加安全风险。

解决方案

为了应对上述风险,企业A可以在原有方案的基础上增加临时授权。通过这种方式,企业A能够在确保数据直传效率的同时,实现以下效果:

  • 增强的身份验证和授权:通过STS生成的具有时间限制的令牌,即便在短时间内泄露,也极大降低了安全风险。因为这些凭证很快就会失效,降低了被不当利用的可能性。

  • 精细化权限控制:STS允许根据最小权限原则配置权限,仅授权Web应用必需的访问权限。这种精细化的权限控制方法限制了潜在泄露的影响范围,防止了过度权限的风险。

企业A最终采取以下方案搭建IMM数据处理服务:

image

方案部署

下面将以一个简单的用户文件上传场景为例,引导您一步步使用OSS、IMM和STS为Web应用部署浏览器数据处理服务。

准备工作

  • 创建一个OSS Bucket和一个IMM Project。

    参数

    示例值

    所属地域

    华东1(杭州)

    Bucket名称

    web-direct-upload

    Project 名称

    web-direct-project

    具体步骤,请参见创建存储空间创建项目

  • 创建一台ECS实例作为业务服务器,用于生成临时身份凭证。

    说明

    在实际部署时,您可以将调用STS服务的接口集成到自己的业务服务器的接口中,无需创建该ECS实例。

    参数

    示例值

    付费类型

    按量付费

    地域

    华东1(杭州)

    公网 IP

    分配公网 IPv4 地址

    安全组

    开放HTTP (TCP:80)端口

    具体步骤,请参见通过控制台使用ECS实例(快捷版)

  • 为创建的OSS Bucket配置跨域资源共享。

    参数

    示例值

    来源

    http://ECS公网IP地址

    允许Methods

    PUT

    允许Headers

    *

    具体步骤,请参见跨域设置

部署步骤

步骤一:在访问控制创建RAM用户

首先,创建一个调用方式为OpenAPI调用的RAM用户,并获取对应的访问密钥,作为业务服务器的应用程序的长期身份凭证。

  1. 使用云账号或账号管理员登录RAM控制台

  2. 在左侧导航栏,选择身份管理 > 用户

  3. 单击创建用户

  4. 输入登录名称显示名称

  5. 访问方式区域下,选择OpenAPI 调用访问,然后单击确定

  6. 单击复制,保存调用密钥(AccessKey IDAccessKey Secret)。

步骤二:在访问控制为RAM用户授予调用AssumeRole接口的权限

创建RAM用户后,需要授予RAM用户调用STS服务的AssumeRole接口的权限,使其可以通过扮演RAM角色来获取临时身份凭证。

  1. 在左侧导航栏,选择身份管理 > 用户

  2. 用户页面,找到目标RAM用户,然后单击RAM用户右侧的添加权限

  3. 新增授权页面,选择AliyunSTSAssumeRoleAccess系统策略。

    说明

    授予RAM用户调用STS服务AssumeRole接口的固定权限是AliyunSTSAssumeRoleAccess,与后续获取临时访问凭证以及通过临时访问凭证发起IMM请求所需权限无关。

  4. 单击确认新增授权

步骤三:在访问控制创建RAM角色

为当前云账号创建一个RAM角色,并获取其ARN,用于RAM用户之后进行扮演。

  1. 在左侧导航栏,选择身份管理 > 角色

  2. 单击创建角色,可信实体类型选择阿里云账号,单击下一步

  3. 填写角色名称,选择当前云账号

  4. 单击完成。完成角色创建后,单击关闭

  5. 在RAM角色管理页面,搜索框输入角色名称,例如imm-web-upload

  6. 点击角色名称,单击复制,保存角色的ARN。

    image

步骤四:在访问控制创建自定义权限策略

创建OSS授权策略

按照最小授权原则,为RAM角色创建一个自定义权限策略,限制只能向指定的OSS Bucket上传文件。

  1. 在左侧导航栏,选择权限管理 > 权限策略

  2. 单击创建权限策略

  3. 创建权限策略页面,单击脚本编辑,将以下脚本中的<Bucket名称>替换为web-direct-upload

重要

以下示例仅供参考。您需要根据实际需求配置更细粒度的授权策略,防止出现权限过大的风险。关于更细粒度的授权策略配置详情,请参见对象存储自定义权限策略参考

{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "oss:PutObject",
      "Resource": "acs:oss:*:*:<Bucket名称>/uploads/*"
    }
  ]
}
  1. 策略配置完成后,单击继续编辑基本信息

  2. 基本信息区域,填写策略名称,然后单击确定。

创建IMM授权策略

按照最小授权原则,为RAM角色创建一个自定义权限策略,创建步骤同OSS授权策略,限制只能对指定的IMM项目进行操作。

如下为策略脚本:

重要

以下示例仅供参考。您需要根据实际需求配置更细粒度的授权策略,防止出现权限过大的风险。关于更细粒度的授权策略配置详情,请参见IMM授权策略参考

{
    "Version": "1",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "imm:*",
            "Resource": "acs:imm:cn-hangzhou:1413397765616316:project/<PROJECT名称>"
        }
    ]
}

步骤五:在访问控制为RAM角色授予权限

为RAM角色授予创建的自定义权限,以便该RAM角色被扮演时能获取所需的权限。

  1. 在左侧导航栏,选择身份管理 > 角色

  2. 角色页面,找到目标RAM角色,然后单击RAM角色右侧的新增授权

  3. 新增授权页面下的自定义策略页签,选择已创建的自定义权限策略。

  4. 单击确定

步骤六:在业务服务器获取临时身份凭证

在Web应用中,通过在业务服务器集成STS SDK,实现一个获取临时STS身份凭证的接口。当这个接口(/get_sts_token)通过HTTP GET方法被访问时,它会生成一个临时身份凭证,并将其返回给请求者。

在ECS实例上,使用Flask框架快速搭建Web应用,实现一个获取临时STS身份凭证的接口的操作示例如下:

  1. 连接ECS实例。

    具体操作,请参见通过控制台使用ECS实例(快捷版)

  2. 安装Python3

  3. 创建项目文件夹,然后切换到项目目录。

    mkdir my_web_sample
    cd my_web_sample
  4. 安装依赖。

    pip3 install Flask
    pip3 install attr
    pip3 install yarl
    pip3 install async_timeout
    pip3 install idna_ssl
    pip3 install attrs
    pip3 install aiosignal
    pip3 install charset_normalizer
    pip3 install alibabacloud_tea_openapi
    pip3 install alibabacloud_sts20150401
    pip3 install alibabacloud_credentials
  5. 编写后端代码。

    1. 创建一个main.py文件。

    2. 在这个文件中,添加以下Python代码。

      import json
      from flask import Flask, render_template
      from alibabacloud_tea_openapi.models import Config
      from alibabacloud_sts20150401.client import Client as Sts20150401Client
      from alibabacloud_sts20150401 import models as sts_20150401_models
      from alibabacloud_credentials.client import Client as CredentialClient
      
      app = Flask(__name__)
      
      # 将<YOUR_ROLE_ARN>替换为RAM角色的ARN。
      role_arn_for_oss_upload = '<YOUR_ROLE_ARN>'
      # 设置为STS服务的地域,例如cn-hangzhou。
      region_id = 'cn-hangzhou'
      @app.route("/imm")
      def imm():
          return render_template('imm_example.html')
      @app.route('/get_sts_token', methods=['GET'])
      def get_sts_token():
          # 初始化 CredentialClient 时不指定参数,代表使用默认凭据链。
          # 在本地运行程序时,可以通过环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID、ALIBABA_CLOUD_ACCESS_KEY_SECRET 指定 AK;
          # 在 ECS\ECI\容器服务上运行时,可以通过环境变量 ALIBABA_CLOUD_ECS_METADATA 来指定绑定的实例节点角色,SDK 会自动换取 STS 临时凭证。
          config = Config(region_id=region_id, credential=CredentialClient())
          sts_client = Sts20150401Client(config=config)
          assume_role_request = sts_20150401_models.AssumeRoleRequest(
              role_arn=role_arn_for_oss_upload,
              # 将<YOUR_ROLE_SESSION_NAME>设置为自定义的会话名称。
              role_session_name='<YOUR_ROLE_SESSION_NAME>'
          )
          response = sts_client.assume_role(assume_role_request)
          token = json.dumps(response.body.credentials.to_map())
          return token
      
      app.run(host="0.0.0.0", port=80) 
    3. 将代码中的<YOUR_ROLE_ARN>替换为步骤三获取的角色ARN。

    4. 将代码中的<YOUR_ROLE_SESSION_NAME>设置为自定义的会话名称,例如role_session_test

  6. 使用步骤一获取的访问密钥启动应用程序。

    ALIBABA_CLOUD_ACCESS_KEY_ID=<YOUR_AK_ID> ALIBABA_CLOUD_ACCESS_KEY_SECRET=<YOUR_AK_SECRET> python3 main.py
  7. 在浏览器中访问http://<ECS实例公网IP地址>/get_sts_token

    成功返回示例如下:

    sts token.png

步骤七:在浏览器使用临时身份凭证调用IMM服务

在业务服务器配置了获取STS临时身份凭证的接口后,在Web应用的前端网页使用CDN引入OSS JavaScript SDK,实现对文件上传的监听。当用户上传文件,调用/get_sts_token接口从业务服务器请求临时访问凭证,然后使用临时访问凭证向OSS上传文件。当通过Web端调用IMM服务时,需要使用HMAC算法对数据加密,此例子中使用第三方库crypto.js中的HmacSHA1来实现,然后使用临时访问凭证调用DetectImageFaces接口获取返回值。

在ECS上,将前端代码集成到Web应用的操作示例如下:

  1. Ctrl + C停止应用程序。

  2. 创建前端项目文件。

    mkdir templates
  3. 创建HTML模板文件。

    1. templates目录中创建一个imm_example.html文件。

      vim templates/imm_example.html
    2. 在这个文件中,添加以下HTML代码。该页面的功能是在浏览器中模拟用户选择并上传文件,通过调用IMM服务检测图片中的人脸以及人脸信息

      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <title>imm_example</title>
          <script src="https://gosspublic.alicdn.com/aliyun-oss-sdk-6.17.0.min.js"></script>
          <script src="https://unpkg.com/crypto-js@4.1.1/crypto-js.js"></script>
          <style>
            .app form div {
              display: flex;
              margin: 12px;
            }
            .app label {
              display: flex;
              width: 100px;
            }
            .app form input {
              width: 200px;
            }
          </style>
        </head>
        <body>
          <div class="app">
            <h2>1.OSS文件上传:</h2>
            <form id="oss-upload-form">
              <div>
                <label for="file" class="form-label">选择文件:</label>
                <input
                  type="file"
                  class="form-control"
                  id="file"
                  name="file"
                  required
                />
              </div>
              <button type="submit" class="btn btn-primary">上传</button>
            </form>
            <div id="oss-result"></div>
          </div>
          <div class="app">
            <h2>2.调用IMM DetectImageFaces服务:</h2>
            <form id="imm-form">
              <button type="submit">Request</button>
            </form>
          </div>
          <div>
            <h2>3.返回结果:</h2>
            <div id="result"></div>
          </div>
          <script>
            /**
             * 判断临时凭证是否到期。
             **/
            function isCredentialsExpired(credentials) {
              if (!credentials) {
                return true;
              }
              const expireDate = new Date(credentials.Expiration);
              const now = new Date();
              // 如果有效期不足一分钟,视为过期。
              return expireDate.getTime() - now.getTime() <= 60000;
            }
      
            let credentials = null;
            let uploadResult = null;
      
            // ############# OSS-UPLOAD #################
            const ossForm = document.querySelector("#oss-upload-form");
            ossForm.addEventListener("submit", async (event) => {
              event.preventDefault();
              // 临时凭证过期时,才重新获取,减少对 sts 服务的调用
              if (isCredentialsExpired(credentials)) {
                const response = await fetch("/get_sts_token", {
                  method: "GET",
                });
                if (!response.ok) {
                  // 处理错误的HTTP状态码
                  throw new Error(
                    `获取STS令牌失败: ${response.status} ${response.statusText}`
                  );
                }
                credentials = await response.json();
              }
              const client = new OSS({
                // 将<YOUR_BUCKET>设置为OSS Bucket名称。
                bucket: "web-direct-upload",
                // 将<YOUR_REGION>设置为OSS Bucket所在地域,例如oss-cn-hangzhou。
                region: "oss-cn-hangzhou",
                accessKeyId: credentials.AccessKeyId,
                accessKeySecret: credentials.AccessKeySecret,
                stsToken: credentials.SecurityToken,
              });
      
              const fileInput = document.querySelector("#file");
              const file = fileInput.files[0];
              const result = await client.put("/" + file.name, file);
              if (result) {
                uploadResult = result;
                document.querySelector("#oss-result").textContent = "上传成功";
              }
            });
      
            // ##################################################
      
            // ################# IMM ############################
            function getTimestamp() {
              const pad2 = (number) => {
                return String(number).padStart(2, "0");
              };
              let date = new Date();
              let YYYY = date.getUTCFullYear();
              let MM = pad2(date.getUTCMonth() + 1);
              let DD = pad2(date.getUTCDate());
              let HH = pad2(date.getUTCHours());
              let mm = pad2(date.getUTCMinutes());
              let ss = pad2(date.getUTCSeconds());
              return `${YYYY}-${MM}-${DD}T${HH}:${mm}:${ss}Z`;
            }
            function generateSecureNonce(length = 32) {
              const array = new Uint8Array(length);
              window.crypto.getRandomValues(array);
              return Array.from(array, (byte) => byte.toString(36))
                .join("")
                .substring(0, length);
            }
      
            function normalize(params) {
              const list = [];
              const flated = params;
              const keys = Object.keys(flated).sort();
              for (let i = 0; i < keys.length; i++) {
                const key = keys[i];
                const value = flated[key];
                list.push([encode(key), encode(value)]);
              }
              return list;
            }
            function canonicalize(normalized) {
              const fields = [];
              for (let i = 0; i < normalized.length; i++) {
                const [key, value] = normalized[i];
                fields.push(key + "=" + value);
              }
              return fields.join("&");
            }
            function encode(str) {
              const result = encodeURIComponent(str);
              return result
                .replace(/!/g, "%21")
                .replace(/'/g, "%27")
                .replace(/\(/g, "%28")
                .replace(/\)/g, "%29")
                .replace(/\*/g, "%2A");
            }
            async function sha1(data, key = null) {
              const keyData = CryptoJS.enc.Utf8.parse(key);
              // 使用HmacSHA1方法计算HMAC
              const hmac = CryptoJS.HmacSHA1(data, keyData);
              // 将HMAC (CryptoJS对象) 转换为 base64 字符串
              const base64Hmac = hmac.toString(CryptoJS.enc.Base64);
              return base64Hmac;
            }
      
            async function getRPCSignature(signedParams, method, secret) {
              const normalized = normalize(signedParams);
              const canonicalized = canonicalize(normalized);
              const stringToSign = `${method}&${encode("/")}&${encode(
                canonicalized
              )}`;
              const key = secret + "&";
              return await sha1(stringToSign, key);
            }
      
            async function getStsToken() {
              const response = await fetch("/get_sts_token", {
                method: "GET",
              });
              if (!response.ok) {
                // 处理错误的HTTP状态码
                throw new Error(
                  `获取STS令牌失败: ${response.status} ${response.statusText}`
                );
              }
              return await response.json();
            }
      
            // let credentials = null;
            async function getParams({ Action, ProjectName, SourceURI }) {
              if (isCredentialsExpired(credentials)) {
                credentials = await getStsToken();
              }
              const params = {
                AccessKeyId: credentials.AccessKeyId,
                Format: "JSON",
                Action,
                ProjectName,
                Timestamp: getTimestamp(),
                SecureTransport: true,
                SignatureMethod: "HMAC-SHA1",
                SignatureNonce: generateSecureNonce(),
                SignatureVersion: "1.0",
                SourceURI,
                Version: "2020-09-30",
                SecurityToken: credentials.SecurityToken,
              };
              params.Signature = await getRPCSignature(
                params,
                "POST",
                credentials.AccessKeySecret
              );
              return params;
            }
            async function detect(requestParams) {
              const params = await getParams(requestParams);
              const url = `${requestParams.EndPoint}?${new URLSearchParams(
                params
              ).toString()}`;
              const response = await fetch(url, {
                method: "POST",
                headers: {
                  "Content-Type": "application/json",
                },
              });
              if (!response.ok) {
                throw new Error(
                  `接口请求失败: ${response.status} ${response.statusText}`
                );
              }
              return await response.json();
            }
      
            const immForm = document.querySelector("#imm-form");
            immForm.addEventListener("submit", (e) => {
              e.preventDefault();
      
              detect({
                // imm服务名称
                Action: "DetectImageFaces",
                // 将<YOUR_PROJECT_NAME>设置为 IMM项目名称
                ProjectName: "web-direct-project",
                // 将<YOUR_OSS_FILE_PATH>设置为文件的OSS存储地址 , 例如:oss://imm-example-cn-hangzhou/test.jpg
                SourceURI: `oss://web-direct-upload/${uploadResult.name}`,
                // 将<YOUR_END_POINT>设置为 IMM服务的endpoint , 例如: https://imm.cn-beijing.aliyuncs.com
                EndPoint: "https://imm.cn-hangzhou.aliyuncs.com",
              })
                .then((res) => {
                  // console.log(res);
                  document.querySelector("#result").textContent = JSON.stringify(res);
                })
                .catch((err) => {
                  document.querySelector("#result").textContent = err;
                });
            });
          </script>
        </body>
      </html>
      
      
  4. 使用步骤一获取的访问密钥启动应用程序。

    ALIBABA_CLOUD_ACCESS_KEY_ID=<YOUR_AK_ID> ALIBABA_CLOUD_ACCESS_KEY_SECRET=<YOUR_AK_SECRET> python3 main.py
  5. 在浏览器中访问http://<ECS实例公网IP地址>/imm,然后在页面中选择需要分析的图片进行上传,模拟真实用户在浏览器的行为。

    image

完成与清理

方案验证

完成以上操作后,您可以查看文件是否已上传到OSS。

  1. 登录对象存储控制台

  2. 在左侧导航栏,选择Bucket列表

  3. Bucket列表页面,单击目标Bucket。

  4. 文件列表页面,查看成功上传的文件。

    image

完成以上操作后,您可以查看IMM返回的分析数值。

image

{
    "RequestId": "63661E75-3A41-5FBF-B023-867DA6A6AA81",
    "Faces": [
        {
            "Beard": "none",
            "MaskConfidence": 0.764,
            "Gender": "female",
            "Boundary": {
                "Left": 182,
                "Top": 175,
                "Height": 381,
                "Width": 304
            },
            "BeardConfidence": 0.987,
            "FigureId": "047d8d12-c3b6-4e22-9b9a-b91facc650fb",
            "Mouth": "open",
            "Emotion": "happiness",
            "Age": 45,
            "MouthConfidence": 0.999,
            "FigureType": "face",
            "GenderConfidence": 1,
            "HeadPose": {
                "Pitch": -16.206,
                "Roll": -5.124,
                "Yaw": 3.421
            },
            "Mask": "none",
            "EmotionConfidence": 0.984,
            "HatConfidence": 1,
            "GlassesConfidence": 0.976,
            "Sharpness": 1,
            "FigureClusterId": "figure-cluster-id-unavailable",
            "FaceQuality": 0.942,
            "Attractive": 0.044,
            "AgeSD": 7,
            "Glasses": "glasses",
            "FigureConfidence": 1,
            "Hat": "none"
        }
    ]
}

清理资源

在本方案中,您创建了1台ECS实例、1个OSS Bucket。测试完方案后,您可以参考以下规则处理对应产品的资源,避免继续产生费用。