前言

传统上,上传文件时,前端先把文件发给后端,后端接收到文件后再落盘或上传至OOS。

随着云服务的发展,前端可以直接上传文件至OOS。但是前端作为第三方向OOS上传文件需要凭证(例如token和秘钥),直接将凭证放入请求体发送上传请求显然是极度危险的。

因此,这里总结一下如何安全地实现前端直传OOS。

传统场景

场景说明

首先复习一下最传统的文件上传场景,即前端发送文件至后端,后端接收文件后上传至OOS或落盘。

文件上传方案.drawio

示例代码

POST请求

image-20240512151138241

Controller

获取post请求体中的参数,包括文件和其他表单数据。

1
2
3
4
5
6
7
@PostMapping("/upload")
public void upload(@RequestParam("file") MultipartFile file,
@RequestParam("uploader") String uploader,
@RequestParam("uploadTime") String uploadTime) {

bpFileService.upload(file, uploader, uploadTime);
}

Service

处理 / 校验文件并落盘。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void uploadToDisk(MultipartFile file, String uploader, String uploadTime) {
String path = "D:\\temp\\bp";
File dir = new File(path);
try {
if (!dir.exists()) {
if (!dir.mkdirs()) {
throw new IOException("Failed to create directory: " + path);
}
}

File diskFile = new File(path, file.getOriginalFilename() + "");
if (!diskFile.createNewFile()) {
throw new IOException("Failed to create file: " + diskFile.getAbsolutePath());
}

file.transferTo(diskFile);
} catch (IOException e) {
// 处理异常情况
e.printStackTrace();
}

BPFile sqlFile = new BPFile(file.getOriginalFilename(), file.getSize()+"", uploader, uploadTime, path);
bpFileDao.insert(sqlFile);
}

STS上传

阿里云STS(Security Token Service)是阿里云提供的一种临时访问权限管理服务。RAM提供RAM用户和RAM角色两种身份。其中,RAM角色不具备永久身份凭证,而只能通过STS获取可以自定义时效和访问权限的临时身份凭证,即安全令牌(STS Token)。

场景说明

在典型的Client/Server架构中,服务器端负责接收并处理客户端的请求,并将OSS作为后端的存储服务。客户端将要上传的文件发送给服务器端,然后服务器端再将数据转发上传到OSS。在这个过程中,一份数据需要在网络上传输两次,分别为从客户端到服务器端,再从服务器端到OSS。当访问量较大时,服务器端需要有足够的带宽资源来满足多个客户端同时上传的需求,这对架构的伸缩性提出了挑战。

为了解决以上场景带来的挑战,OSS提供了授权给第三方上传的功能。使用该功能,每个客户端可以直接将文件上传到OSS而不是通过服务器端转发,不仅节省了自建服务器的成本,而且充分利用了OSS的海量数据处理能力,无需考虑带宽和并发限制等,可以让客户专心于业务处理。

文件上传方案2.drawio

示例代码

POST请求

image-20240512151138241

Controller

获取post请求体中的参数,调用生成临时凭证的API并返回结果。

1
2
3
4
5
6
7
8
@PostMapping("/upload")
public Response<STSCredentials> upload(@RequestParam("file") MultipartFile file,
@RequestParam("uploader") String uploader,
@RequestParam("uploadTime") String uploadTime) {

STSCredentials credentials = bpFileService.upload(file, uploader, uploadTime);
return new Response<>(Code.OK, credentials);
}

POJO

封装返回给前端的临时凭证。

1
2
3
4
5
6
7
8
9
@Data
public class STSCredentials {
private String expiration;
private String accessKeyId;
private String accessKeySecret;
private String securityToken;
private String requestId;
}

Service

调用STSService生成临时凭证。

1
2
3
4
5
6
7
@Autowired
private STSService sts;

@Override
public STSCredentials upload(MultipartFile file, String uploader, String uploadTime) {
return sts.getTempAccessCredentials();
}

通过RAM用户的AccessKeyIdAccessKeySecret生成临时访问凭证。

临时访问凭证包括安全令牌(SecurityToken)、临时访问密钥(AccessKeyId和AccessKeySecret)以及过期时间(Expiration)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@Configuration
public class STSService {

@Value("${OSS_ACCESS_KEY_ID}")
private String accessKeyId;
@Value("${OSS_ACCESS_KEY_SECRET}")
private String accessKeySecret;
@Value("${OSS_STS_ROLE_ARN}")
private String roleArn;
@Value("${BUCKET_NAME}")
private String bucketName;

/**
* 获得临时访问凭证
*/
public STSCredentials getTempAccessCredentials() {

System.out.println(accessKeyId);
System.out.println(accessKeySecret);
System.out.println(roleArn);
System.out.println(bucketName);

// STS服务接入点,例如sts.cn-hangzhou.aliyuncs.com。您可以通过公网或者VPC接入STS服务。
String endpoint = "sts.cn-beijing.aliyuncs.com";
// 自定义角色会话名称,用来区分不同的令牌,例如可填写为SessionTest。
String roleSessionName = "ArccSessionTest";
// 以下Policy用于限制仅允许使用临时访问凭证向目标存储空间examplebucket下的src目录上传文件。
// 临时访问凭证最后获得的权限是步骤4设置的角色权限和该Policy设置权限的交集,即仅允许将文件上传至目标存储空间examplebucket下的src目录。
// 如果policy为空,则临时访问凭证将获得角色拥有的所有权限。
String policy = "{\n" +
" \"Version\": \"1\", \n" +
" \"Statement\": [\n" +
" {\n" +
" \"Action\": [\n" +
" \"oss:PutObject\"\n" +
" ], \n" +
" \"Resource\": [\n" +
" \"acs:oss:*:*:" + bucketName + "/src/*\" \n" +
" ], \n" +
" \"Effect\": \"Allow\"\n" +
" }\n" +
" ]\n" +
"}";
// 临时访问凭证的有效时间,单位为秒。最小值为900,最大值以当前角色设定的最大会话时间为准。当前角色最大会话时间取值范围为3600秒~43200秒,默认值为3600秒。
// 在上传大文件或者其他较耗时的使用场景中,建议合理设置临时访问凭证的有效时间,确保在完成目标任务前无需反复调用STS服务以获取临时访问凭证。
Long durationSeconds = 3600L;
try {
// regionId表示RAM的地域ID。以华东1(杭州)地域为例,regionID填写为cn-hangzhou。也可以保留默认值,默认值为空字符串("")。
String regionId = "cn-beijing";
// 添加endpoint。适用于Java SDK 3.12.0及以上版本。
DefaultProfile.addEndpoint(regionId, "Sts", endpoint);
// 添加endpoint。适用于Java SDK 3.12.0以下版本。
// DefaultProfile.addEndpoint("",regionId, "Sts", endpoint);
// 构造default profile。
IClientProfile profile = DefaultProfile.getProfile(regionId, accessKeyId, accessKeySecret);
// 构造client。
DefaultAcsClient client = new DefaultAcsClient(profile);
final AssumeRoleRequest request = new AssumeRoleRequest();
// 适用于Java SDK 3.12.0及以上版本。
request.setSysMethod(MethodType.POST);
// 适用于Java SDK 3.12.0以下版本。
//request.setMethod(MethodType.POST);
request.setRoleArn(roleArn);
request.setRoleSessionName(roleSessionName);
request.setPolicy(policy);
request.setDurationSeconds(durationSeconds);
final AssumeRoleResponse response = client.getAcsResponse(request);
STSCredentials credentials = new STSCredentials();
credentials.setExpiration(response.getCredentials().getExpiration());
credentials.setAccessKeyId(response.getCredentials().getAccessKeyId());
credentials.setAccessKeySecret(response.getCredentials().getAccessKeySecret());
credentials.setSecurityToken(response.getCredentials().getSecurityToken());
credentials.setRequestId(response.getRequestId());
// System.out.println("Expiration: " + response.getCredentials().getExpiration());
// System.out.println("Access Key Id: " + response.getCredentials().getAccessKeyId());
// System.out.println("Access Key Secret: " + response.getCredentials().getAccessKeySecret());
// System.out.println("Security Token: " + response.getCredentials().getSecurityToken());
// System.out.println("RequestId: " + response.getRequestId());
return credentials;
} catch (ClientException e) {
e.printStackTrace();
System.out.println("Failed:");
System.out.println("Error code: " + e.getErrCode());
System.out.println("Error message: " + e.getErrMsg());
System.out.println("RequestId: " + e.getRequestId());
}
return null;
}
}

表单上传

场景说明

OSS表单上传允许网页应用通过标准HTML表单直接将文件上传至OSS。这种方式下,在前端页面选择文件后,浏览器发起POST请求直接将文件传输到OSS服务器,而无需经过网站服务器中转,减轻了服务器的压力,提高了文件上传的效率和稳定性。

注意:通过表单上传的方式上传的Object大小不能超过5 GB。

使用场景

表单上传广泛应用于Web应用程序,包括但不限于以下几个方面:

  • 用户资料上传:注册账号时上传头像、身份证照片或其他身份验证材料。在个人中心修改信息时上传新的头像或背景图。
  • 文件分享与存储:在网盘服务、协同办公平台等通过表单上传各种格式的文件,如文档、图片、音频、视频等至云端进行存储和共享。
  • 内容创作与发布:在博客、论坛、问答社区等平台编写文章并通过表单上传图片、附件等作为内容补充。
  • 电商管理:商家在电商平台后台上传商品图片、详细描述文件、资质证书等;买家在购买过程中上传发票需求或其他证明材料。
  • 在线教育平台:学生在提交作业或项目时上传文档、PPT、视频等作业文件;教师在课程建设时上传教学资料、课件等。
  • 求职招聘网站:求职者上传简历、作品集等求职材料;企业发布职位时上传公司LOGO、招聘信息文件等。
  • 问卷调查与反馈:填写在线调查问卷时上传附加的证据文件或说明文档。
  • 软件开发协作:在代码托管平台如GitHub、GitLab等上传代码文件或项目文档。

方案概述

在服务端生成PostObject所需的Post签名、PostPolicy等信息,然后客户端可以凭借这些信息,在一定的限制下不依赖OSS SDK直接上传文件。PostPolicy可以用于限制客户端上传的文件,例如限制文件大小、文件类型。

注意:此方案适用于通过HTML表单上传的方式上传文件,不支持基于分片上传大文件、基于分片断点续传的场景。

image

代码示例

配置类

通过RAM用户密钥获取OOS实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Configuration
public class OssConfig {
@Value("${END_POIINT}")
private String endpoint = "oss-cn-beijing.aliyuncs.com";
@Value("${BUCKET_NAME}")
private String bucket ;
/**
* 指定上传到 OSS 的文件前缀
*/
private String dir = "arcc/";
/**
* 指定过期时间,单位为秒
*/
private long expireTime = 3600;
/**
* 构造 host
*/
private String host = "http://" + bucket + "." + endpoint;
@Value("${OSS_ACCESS_KEY_ID}")
private String accessKeyId;
@Value("${OSS_ACCESS_KEY_SECRET}")
private String accessKeySecret;

private OSS ossClient;
@Bean
public OSS getOssClient() {
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
return ossClient;
}
@Bean
public String getHost() {
return host;
}
@Bean
public String getAccessKeyId() {
return accessKeyId;
}
@Bean
public long getExpireTime() {
return expireTime;
}
@Bean
public String getDir() {
return dir;
}

@PreDestroy
public void onDestroy() {
ossClient.shutdown();
}
}

Controller

生成Post签名和Post Policy等信息返回给前端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RestController
public class PostSignatureController {
@Autowired
private OSS ossClient;

@Autowired
private OssConfig ossConfig;

@GetMapping("/get_post_signature_for_oss_upload")
@ResponseBody
public String generatePostSignature() {
JSONObject response = new JSONObject();
try {
long expireEndTime = System.currentTimeMillis() + ossConfig.getExpireTime() * 1000;
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, ossConfig.getDir());
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
response.put("ossAccessKeyId", ossConfig.getAccessKeyId());
response.put("policy", encodedPolicy);
response.put("signature", postSignature);
response.put("dir", ossConfig.getDir());
response.put("host", ossConfig.getHost());
} catch (
OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
// 假设此方法存在
System.out.println("HTTP Status Code: " + oe.getRawResponseError());
System.out.println("Error Message: " + oe.getErrorMessage());
System.out.println("Error Code: " + oe.getErrorCode());
System.out.println("Request ID: " + oe.getRequestId());
System.out.println("Host ID: " + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message: " + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
return response.toString();
}
}
}

两者异同

STS和表单上传都能做到权限控制与定时销毁。

不同的是,表单上传在客户端通过JavaScript代码完成签名,然后通过表单直传数据到OSS,期间完全不需要服务端的参与。当然,如若设计更加复杂的权限控制、签名管理或设置上传回调等功能,就需要服务端的参与。

详细见:如何在Web端通过表单上传数据到OSS_对象存储(OSS)-阿里云帮助中心 (aliyun.com)

因此,表单上传适用于简单的文件上传场景,特别是在需要向公众开放上传功能时比较常见。

而STS授权更适用于需要更严格的权限控制或者需要将上传任务分发给第三方的场景,例如,将上传任务分发给移动端应用或者其他服务器。相比于表单上传有更高的灵活性和安全性,但是也更复杂一点。

其实还是搞不太清楚……姑且先这么理解吧QࡇQ

参考链接

问题记录

Q:我在Spring boot项目中定义了一个类,类中使用@Value注解注入了application.yml中的环境变量,类本身没有任何注解。我在其他类中调用该类时,使用new语句创建出的该类的示例拿不到环境变量,但是使用@Autowired注解注入该类的示例可以拿到环境变量,这是为什么?

A:在 Spring Boot 项目中,使用 @Value 注解注入的属性是由 Spring 容器管理的 bean 属性。当你使用 new 关键字手动创建一个类的实例时,该实例并不是 Spring 容器所管理的 bean,因此无法进行依赖注入。

而当你使用 @Autowired 注解注入该类的示例时,Spring 容器会负责创建该类的实例,并且在实例化过程中注入依赖(比如 @Value 注解注入的属性),因此你能够获取到环境变量的值。

简而言之,使用 @Autowired 注解注入的示例是由 Spring 容器管理的 bean,可以享受到 Spring 提供的各种特性,包括依赖注入。而使用 new 关键字手动创建的示例则不受 Spring 容器的管理,因此无法实现依赖注入。