前端读写阿里云OSS的两种临时授权方式

Node.js服务利用阿里云STS token以及签名URL获取临时访问凭据,在前端实现安全资源读写

在网站开发中经常需要用户读写资源到对象存储(如阿里云OSS),尤其是更加敏感的上传(写)操作,我们不可能把bucket设置为公共写权限。比如上传头像、投稿文件等,常见的做法是后端接收资源并将其上传到阿里云OSS,但这样一来,如果是大文件,会严重占用后端服务器的带宽和存储空间,并且由于服务器的带宽限制,用户上传大文件时会非常慢。

为了解决此问题,阿里云给我们提供了两种方式,让我们选择在前端完成将文件上传到OSS。我们知道,如果直接把永久AccessKeyId和AccessKeySecret暴露在前端,那么后果不堪设想。因此,我们使用下面两种安全的方式来实现临时授权。

  1. 使用STS token来实现临时授权。
  2. 使用签名URL来实现临时授权。

使用STS token

官方文档

阿里云STS token提供了一个接口,我们能够获取一个临时的AccessKeyId和AccessKeySecret,这个凭据的权限是受限的(能够访问到有限的资源),并且有明确的过期时间,这样我们就可以在前端安全地完成文件上传,并且不用担心资源被滥用。

首先,我们到阿里云控制台的RAM访问控制,创建一个RAM用户

这里解释一下RAM用户和角色的区别:

  • RAM用户:是RAM的一种实体身份类型,有确定的身份ID和身份凭证,通常与某个确定的人或应用程序一一对应。RAM用户由阿里云账号(主账号)或具有管理员权限的其他RAM用户、RAM角色创建,创建成功后归属于该阿里云账号
  • RAM 角色:是一种虚拟用户,可以被授予一组权限策略。与RAM用户不同,RAM角色没有永久身份凭证(登录密码或访问密钥),需要被一个可信实体扮演。扮演成功后,可信实体将获得RAM角色的临时身份凭证,即安全令牌(STS Token),使用该安全令牌就能以RAM角色身份访问被授权的资源。

RAM角色适用于移动应用直传等场景,服务端下发临时安全令牌(STS Token),客户端通过临时安全令牌进行资源直传,避免服务端中转带来的多余开销。

阿里云官方文档

创建用户后,记下这个用户的AccessKeyId和AccessKeySecret,然后,我们为这个用户添加权限策略,我们选择AliyunSTSAssumeRoleAccess,这个权限是必须的,赋予用户扮演RAM角色的权限。

然后,我们新建一个RAM角色,可信实体选择阿里云账号,角色名我们命名为submission,表示用户读写文件专用的角色。

然后,我们进入左边的“权限策略”,新建一个自定义策略,假设我们要允许此角色读写(Put和Get)mybucket这个bucket下的a/b/c/这三个目录下的文件,那么我们就可以新建一个策略,内容如下:

 1{
 2    "Version": "1",
 3    "Statement": [
 4        {
 5            "Effect": "Allow",
 6            "Action": [
 7                "oss:PutObject",
 8                "oss:GetObject"
 9            ],
10            "Resource": [
11                "acs:oss:*:*:mybucket/a/*",
12                "acs:oss:*:*:mybucket/b/*",
13                "acs:oss:*:*:mybucket/c/*"
14                // 如果要允许读写目录本身,则需要添加:
15                // "acs:oss:*:*:mybucket/a/",
16                // "acs:oss:*:*:mybucket/b/",
17                // "acs:oss:*:*:mybucket/c/"
18            ]
19        }
20    ]
21}

创建完成后,我们回到角色管理,为这个角色添加刚刚创建的策略,资源级别选择“账号级别”。然后,记下这个角色的ARN,即:

后端接口中,我们就可以使用STS服务:

 1const OSS = require('ali-oss');
 2const STS = require('ali-oss').STS;
 3const express = require('express');
 4const app = express();
 5
 6app.use(express.json());
 7const sts = new STS({
 8    accessKeyId: 'yourAccessKeyId',
 9    accessKeySecret: 'yourAccessKeySecret',
10    // 填入刚刚获得的RAM用户的AccessKeyId和AccessKeySecret
11}); 
12
13app.get('/get-sts', async (req, res) => {
14    // 完成用户权限验证,如通过token获取id
15    const userId = req.user.id;
16
17    const result = await sts.assumeRole(
18        'acs:ram::10**********:role/submission', // 填入刚刚获得的RAM角色的ARN
19        '', // 可选,填入自定义策略,最终角色获得的权限是此策略和RAM角色的权限的交集
20        900, // 填入临时凭证的有效期,单位为秒,最小值是900秒,最大值是控制台给角色设定的最大会话时间
21        'session' + userId // 填入一个自定义的会话名称。当使用相同的会话名调用assumeRole方法时,新的会话将立即生效,旧的会话将被终止。所以,使用userId等标识来区分不同的会话是很好的做法。
22    );
23    // 返回临时凭证
24    res.json({
25        accessKeyId: result.Credentials.AccessKeyId,
26        accessKeySecret: result.Credentials.AccessKeySecret,
27        securityToken: result.Credentials.SecurityToken,
28        expiration: result.Credentials.Expiration
29    });
30});

前端可以调用这个接口,获取临时凭证,然后使用这个凭证来完成文件的上传和下载。

 1<!-- 需要先引入ali-oss -->
 2<script src="https://unpkg.com/ali-oss"></script>
 3<script>
 4const { accessKeyId, accessKeySecret, securityToken, expiration } = await result.json();
 5
 6async function getSTSToken() {
 7    // 前端身份验证操作,如通过token获取id
 8    const result = await fetch('/get-sts');
 9    return result.json();
10}
11
12const client = new OSS({
13    region: 'oss-cn-hangzhou', // 填入你的bucket所在的地域
14    accessKeyId: result.accessKeyId, // 临时凭证的AccessKeyId
15    accessKeySecret: result.accessKeySecret, // 临时凭证的AccessKeySecret
16    stsToken: result.securityToken, // 临时凭证的SecurityToken
17    bucket: 'mybucket', // 填入你的bucket名称
18    endpoint: 'https://yourdomain.com' // 如果有自定义域名就填
19    cname: true, // 如果使用自定义域名,则需要设置为true
20    secure: true, // 如果强制https,则需要设置为true
21    refreshSTSToken: async () => {
22        const refreshToken = await getSTSToken();
23        return {
24            accessKeyId: refreshToken.accessKeyId,
25            accessKeySecret: refreshToken.accessKeySecret,
26            securityToken: refreshToken.securityToken,
27        }
28    }, // 自动刷新临时凭证的函数
29    refreshSTSTokenInterval: 1000 * 60 * 5 // 设置临时凭证的刷新时间,单位为毫秒,刚刚我们设置了900秒,这里可以设置得短一些,为5分钟
30});
31
32client.put('a/example.file', file); // 上传文件
33client.get('a/example.file'); // 下载文件
34client.put('d/example.file', file); // 如果访问权限外的目录,会报403错误
35</script>

使用签名URL

官方文档

签名URL是OSS提供的一种临时授权方式,后端服务器只需要完成请求的身份验证然后返回一个签名URL,前端就可以直接利用这个URL在前端完成文件的上传和下载,而不需要通过后端中转文件。

后端接口中,我们只需要完成请求的身份验证,然后返回一个签名URL。注意,你的accessKey用户需要具有bucket的相关操作权限。

 1const OSS = require('ali-oss');
 2const express = require('express');
 3const app = express();
 4
 5app.use(express.json());
 6const client = new OSS({
 7    region: 'oss-cn-hangzhou', // 填入你的bucket所在的地域
 8    accessKeyId: 'yourAccessKeyId', // 填入你的AccessKeyId
 9    accessKeySecret: 'yourAccessKeySecret', // 填入你的AccessKeySecret
10    bucket: 'mybucket', // 填入你的bucket名称
11    endpoint: 'https://yourdomain.com', // 如果有自定义域名就填
12    cname: true, // 如果使用自定义域名,则需要设置为true
13    secure: true, // 如果强制https,则需要设置为true
14    authorizationV4: true // 按阿里云官方文档,使用V4签名
15});
16
17app.get('/get-url', async (req, res) => {
18    // 完成用户权限验证,如通过token获取id
19    const userId = req.user.id;
20
21    const { fileKey } = req.query;
22    const url = await client.signatureUrlV4(
23        'GET', // 设置请求方法
24        20, // 设置签名URL的有效期,单位为秒
25        {
26            headers: {
27            // 设置请求头部信息
28            },
29            queries: {
30            // 设置请求参数
31                'response-content-disposition': 'attachment' // 如果要强制浏览器下载,则需要设置此参数
32            // 还可以自定义文件名
33            // 'response-content-disposition': 'attachment; filename="example.file"'
34            }
35        },
36        fileKey // 填入文件路径
37    );
38    res.json({ url });
39});

注意:signatureUrlV4方法并不是返回一个URL,而是返回一个包含URL的Promise对象。我一开始没用await,结果导致返回了一个空对象{},在URL中呈现的是[Object Object]

然后前端就可以轻松地完成文件的上传和下载了。

1<script>
2    const { url } = await result.json();
3    const downloadFile = await fetch(url); // 下载文件
4    const uploadFile = await fetch(url, {
5        method: 'PUT',
6        body: file
7    }); // 上传文件,生成签名时要使用'PUT'方法
8</script>