OSS实践
背景
最近在尝试修改两年以前的项目代码,由于原先代码中的对象存储(OSS)功能使用的是阿里云提供的OSS服务,而我所持有的OSS已经过期,因此需要尝试获取新的OSS服务。正好手上有一台空闲的腾讯云服务器,因此打算尝试在腾讯云上搭建一个文件服务器来存放原先项目中的文件。
目标
原先代码中主要使用了OSS的以下功能:
- 上传文件
- 删除文件
- 图片预览
因此我只需要保证能够在腾讯云上部署一个文件服务器,并且提供对应接口即可。
方案
AliOSS服务中存在几个比较重要的概念:
- Bucket:存储桶,类似于文件夹,用来存放文件。
- AccessKey(AK): 访问密钥,用于身份验证。
- AccessKeySecret(SK):访问密钥密钥,用于身份验证。
- Signature:签名,用于验证请求的合法性。
其中,AK/SK验证的底层逻辑大致如下:
- 客户端准备原始请求数据,包括请求方法、时间戳、请求路径、请求参数等。
- 客户端使用自己的 SK (Secret Key) 对该字符串进行 HMAC-SHA1 或 HMAC-SHA256 计算,生成一个数字签名(Signature)。
- 客户端将 AK、Signature 以及原始数据发送给云端。
- 云端根据传入的 AK,从数据库中查到该用户对应的 SK。云端使用同样的算法和步骤,用该 SK 重新计算一遍签名。如果云端计算出的签名与客户端传入的签名完全一致,验证通过。
安全性分析:
- SK在过程中不进行网络传输,验证过程中只传输"基于 SK 的计算结果(签名)"。中间人抓包只能看到签名,无法通过签名计算出 SK。
- 签名中包含 Timestamp (时间戳)。云端会校验时间戳是否在合理范围(例如 15 分钟内)。如果中间人拦截了请求想在 1 小时后再次发送,云端会直接拒绝。
- 签名是将请求所有关键部分(方法、路径、Body 的哈希值)全部参与运算的。如果改动了请求里的参数,计算出来的签名就会完全不同,云端验证即告失败。(以下签名封装仅为示例,实际签名计算过程更加复杂)
以下是具体代码实现:
1.搭建文件服务器
使用python语言搭建一个文件服务器,使用Flask框架,简易搭建上面提到的几个接口。
import os
from flask import Flask, request, send_from_directory, jsonify
from werkzeug.utils import secure_filename
import hmac
import hashlib
import time
app = Flask(__name__)
UPLOAD_ROOT = 'storage'
# 模拟OSS配置信息,实际开发中可以从数据库中读取
CREDENTIALS = {
"ACCESS_KEY_ID": {"secret": "ACCESS_KEY_SECRET"}
}
def verify_signature():
# 1. 获取 Header 信息
signature = request.headers.get('x-client-signature')
timestamp = request.headers.get('x-client-timestamp')
ak = request.headers.get('x-client-ak')
if not signature or not timestamp or not ak:
return False
# 2. 校验时间戳 (5 分钟防重放)
if abs(time.time() * 1000 - int(timestamp)) > 300000:
return False
# 3. 查找 SK
auth = CREDENTIALS.get(ak)
if not auth:
return False
sk = auth['secret']
# 4. 动态获取当前请求的路径和方法
raw_data = f"{request.method}\n{timestamp}"
expected_signature = hmac.new(
sk.encode('utf-8'),
raw_data.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature)
@app.route('/upload', methods=['POST'])
def upload_single_file():
if not verify_signature():
return jsonify({"error": "Unauthorized"}), 401
if 'file' not in request.files:
return jsonify({"error": "No file part"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"error": "No selected file"}), 400
bucket = request.headers.get('x-client-bucket')
bucket_path = os.path.join(UPLOAD_ROOT, bucket)
os.makedirs(bucket_path, exist_ok=True)
filename = secure_filename(file.filename)
file.save(os.path.join(bucket_path, filename))
view_url = "/view/{}/{}".format(bucket, filename)
# 修正:返回 JSON 格式
return jsonify({"message": "Upload success", "url": view_url}), 201
# --- 新增删除功能 ---
@app.route('/delete/<filename>', methods=['DELETE'])
def delete_file(filename):
# 1. 鉴权
if not verify_signature():
return jsonify({"error": "Unauthorized"}), 401
bucket = request.headers.get('x-client-bucket')
# 安全处理文件名,防止 ../ 攻击
safe_filename = secure_filename(filename)
file_path = os.path.join(UPLOAD_ROOT, bucket, safe_filename)
# 2. 检查并删除
if os.path.exists(file_path):
try:
os.remove(file_path)
return jsonify({"message": "File deleted successfully", "filename": safe_filename}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
else:
return jsonify({"error": "File not found"}), 404
# --------------------
@app.route('/view/<bucket>/<filename>')
def view_file(bucket, filename):
return send_from_directory(os.path.join(UPLOAD_ROOT, bucket), filename)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
这里做了一个很基础的OSS服务,主要是实现了上传、删除、预览文件功能。
2,客户端请求
客户端使用了Java来做请求,主要是使用了WebClient来请求文件服务器的接口。
-
文件服务器配置属性(FilelServerProperties.java)
package com.mental.web.pojo;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;@Data
@Component
@ConfigurationProperties(prefix = "fileserver")
public class FileServerProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
} -
WebClient配置相关代码(FileServerWebClientConfig.java)
package com.mental.web.config;
import com.mental.web.pojo.FileServerProperties;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;@Component
@Slf4j
public class FileServerWebClientConfig {
@Autowired
FileServerProperties fileServerProperties;@Bean public WebClient webClient() { HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) // 连接超时 .doOnConnected(conn -> conn .addHandlerLast(new ReadTimeoutHandler(10)) // 读取超时 .addHandlerLast(new WriteTimeoutHandler(10))); // 写入超时 // 确保 baseUrl 不以斜杠结尾,方便后续使用相对路径(如 "upload"、"cancelUpload") String rawEndpoint = fileServerProperties.getEndpoint(); String baseUrl = (rawEndpoint != null) ? rawEndpoint.replaceAll("/+$", "") : ""; ExchangeFilterFunction logRequest = ExchangeFilterFunction.ofRequestProcessor(clientRequest -> { log.info("WebClient request: {} {}", clientRequest.method(), clientRequest.url()); clientRequest.headers().forEach((name, values) -> log.info("Request header: {} = {}", name, values)); return Mono.just(clientRequest); }); return WebClient.builder() .baseUrl(baseUrl) .defaultHeader("x-client-ak", fileServerProperties.getAccessKeyId()) .defaultHeader("x-client-bucket", fileServerProperties.getBucketName()) .clientConnector(new ReactorClientHttpConnector(httpClient)) .filter(logRequest) .build(); }}
-
文件服务器工具类(FileServerUtils.java)
package com.mental.web.utils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.mental.web.pojo.FileServerProperties;
import com.mental.web.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriUtils;
import reactor.core.publisher.Mono;import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;@Component
@Slf4j
public class FileServerUtils {@Autowired private FileServerProperties fileServerProperties; @Autowired private WebClient webClient; /** * 异步上传文件到文件服务器,返回服务端返回的URL(Mono),出错会在Mono中传播错误 * * @param file 待上传的文件 * @return Mono<String> 服务端返回的文件访问URL */ public Mono<String> uploadAsync(MultipartFile file) { try { // 使用自定义 Resource,以便包含文件名和长度 MultipartInputStreamFileResource resource = new MultipartInputStreamFileResource(file.getInputStream(), file.getOriginalFilename(), file.getSize()); // 构造 multipart/form-data 请求体 MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); body.add("file", resource); // 构造签名字符串 String timestamp = String.valueOf(System.currentTimeMillis()); String rawData = "POST" + "\n" + timestamp; String signature = SignUtils.calculateHMAC(fileServerProperties.getAccessKeySecret(), rawData); // 使用 exchangeToMono 捕获非 2xx 响应并把响应体作为异常信息传播 return webClient.post() .uri("upload") .header("x-client-timestamp", timestamp) .header("x-client-signature", signature) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(body)) .exchangeToMono((ClientResponse resp) -> { if (resp.statusCode().is2xxSuccessful()) { return resp.bodyToMono(String.class); } else { return resp.bodyToMono(String.class) .defaultIfEmpty(resp.statusCode().toString()) .flatMap(b -> Mono.error(new RuntimeException("文件服务器返回错误: " + resp.statusCode() + " body: " + b))); } }) .doOnSuccess(message -> log.info("File uploaded successfully. {}", message)) .doOnError(error -> log.error("File upload failed: {}", error.getMessage())); } catch (IOException e) { log.error("获取文件输入流失败: {}", e.getMessage()); return Mono.error(e); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } catch (InvalidKeyException e) { throw new RuntimeException(e); } } /** * 同步上传(阻塞),如果上传失败会抛出异常,方便Controller捕获并反馈给前端 */ public String upload(MultipartFile file) { try { String serverUrl = uploadAsync(file).block(); // 如果服务端返回了完整url则直接返回;否则,按配置构造访问URL JSONObject jsonObject = JSON.parseObject(serverUrl); if (jsonObject.containsKey("url")) { return fileServerProperties.getEndpoint() + jsonObject.getString("url"); } String endpoint = fileServerProperties.getEndpoint(); String bucketName = fileServerProperties.getBucketName(); return endpoint + "/" + "view/" + bucketName + "/" + file.getOriginalFilename(); } catch (Exception ex) { log.error("同步上传失败: {}", ex.getMessage()); throw new RuntimeException("文件上传失败", ex); } } /** * 异步删除文件服务器上的文件,返回Mono以便上层处理错误 * * @param url 待删除文件的URL */ public Mono<Void> deleteAsync(String url) { // url 可能是完整 URL,也可能只是 filename;需要提取 filename if (url == null) { return Mono.error(new IllegalArgumentException("url is null")); } String filename = url.trim(); // 如果是 JSON 字符串(带引号),去掉引号 if (filename.length() >= 2 && filename.startsWith("\"") && filename.endsWith("\"")) { filename = filename.substring(1, filename.length() - 1); } // 如果传入的是完整 URL,取最后一段作为文件名(去掉查询参数) int qIdx = filename.indexOf('?'); if (qIdx >= 0) { filename = filename.substring(0, qIdx); } int lastSlash = filename.lastIndexOf('/'); if (lastSlash >= 0) { filename = filename.substring(lastSlash + 1); } // 对 path segment 做编码 String encodedFilename = UriUtils.encodePathSegment(filename, StandardCharsets.UTF_8); final String finalFilename = filename; try { // 构造签名字符串 String timestamp = String.valueOf(System.currentTimeMillis()); String rawData = "DELETE" + "\n" + timestamp; String signature = SignUtils.calculateHMAC(fileServerProperties.getAccessKeySecret(), rawData); return webClient.delete() .uri(uriBuilder -> uriBuilder.path("delete/{filename}").build(encodedFilename)) .header("x-client-timestamp", timestamp) .header("x-client-signature", signature) .exchangeToMono((ClientResponse resp) -> { if (resp.statusCode().is2xxSuccessful()) { return resp.bodyToMono(Void.class); } else { return resp.bodyToMono(String.class) .defaultIfEmpty(resp.statusCode().toString()) .flatMap(b -> Mono.error(new RuntimeException("删除文件失败, status=" + resp.statusCode() + ", body=" + b))); } }) .doOnSuccess(unused -> log.info("File deleted successfully. filename: {}", finalFilename)) .doOnError(error -> log.error("File deletion failed: {}", error.getMessage())); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("生成签名失败: " + e.getMessage(), e); } } /** * 同步删除(阻塞),如果删除失败会抛出异常,方便Controller捕获并反馈给前端 */ public void delete(String url) { try { deleteAsync(url).block(); } catch (Exception ex) { log.error("同步删除失败: {}", ex.getMessage()); throw new RuntimeException("文件删除失败", ex); } }}
-
签名工具类(SignUtils.java)
package com.mental.web.utils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;public class SignUtils {
/** * Java 11 HMAC-SHA256 签名实现 */ public static String calculateHMAC(String secretKey, String data) throws NoSuchAlgorithmException, InvalidKeyException { String algorithm = "HmacSHA256"; // 1. 初始化 SecretKeySpec SecretKeySpec secretKeySpec = new SecretKeySpec( secretKey.getBytes(StandardCharsets.UTF_8), algorithm ); // 2. 初始化 Mac 对象 Mac mac = Mac.getInstance(algorithm); mac.init(secretKeySpec); // 3. 计算摘要 byte[] rawHmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); // 4. 将字节数组转换为十六进制字符串 (Java 11 兼容版) return bytesToHex(rawHmac); } private static String bytesToHex(byte[] bytes) { StringBuilder hexString = new StringBuilder(); for (byte b : bytes) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) { hexString.append('0'); } hexString.append(hex); } return hexString.toString(); }}