对象存储由于具有高空间、低费用、可直接URL调用访问等特点,是最常见的存储方式之一。主流的云厂商都有相关的云服务,AWS的s3、Azure Blob stroage、GCP cloud stroage、华为阿里的OBS。在开启匿名访问后,任何人都可以通过对应的URL访问到对应的对象,所以出于安全原因,很多会通过ak/sk的方式生成调用的 signature,并结合expires过期时间来保证链接的盗用。本篇就使用huaweicloud OBS的API 生成对应的对象下载或调用链接。

一、Signature生成

我这里选用了 URL中携带签名的方式,根据官方文档内容,其实比较关键的第一步是拼出 stringtosign 的内容。

1StringToSign = 
2     HTTP-Verb + "\n" +   
3     Content-MD5 + "\n" +   
4     Content-Type + "\n" +   
5     Expires + "\n" +   
6     CanonicalizedHeaders +   CanonicalizedResource; 

接下来算签名,步骤如下:

1、构造请求字符串(StringToSign)。
2、对第一步的结果进行UTF-8编码。
3、使用SK对第二步的结果进行HMAC-SHA1签名计算。
4、对第三步的结果进行Base64编码。
5、对第四步的结果进行URL编码,得到签名。

对应的代码如下:

1hashed = hmac.new(yourSecretAccessKeyID.encode('UTF-8'), canonical_string.encode('UTF-8'),hashlib.sha1)    
2encode_canonical = binascii.b2a_base64(hashed.digest())[:-1].decode('UTF-8')

二. python实现

官方提供了JAVA实现的代码,相较于JAVA,我更喜欢用 python 来实现这种简单功能,具体代码如下:

 1import sys
 2import hashlib
 3import hmac
 4import binascii
 5#from datetime import datetime,timedelta,fromtimestamp
 6import datetime
 7import urllib.request
 8IS_PYTHON2 = sys.version_info.major == 2 or sys.version < '3'
 9
10
11def get_str_timestamp(date):
12    # date = datetime.datetime.now()
13    return int(date.timestamp())
14
15yourSecretAccessKeyID = 'xxxxxxxxxxxxxxx'
16httpMethod = "GET"
17contentmd5 = ""
18#contentType = "application/octet-stream"
19contentType = ""
20# "date" is the time when the request was actually generated
21# now = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
22now = datetime.datetime.utcnow()
23plus = datetime.timedelta(days=2)
24dateplus = now + plus
25date = dateplus.strftime('%a, %d %b %Y %H:%M:%S GMT')
26
27date_stamp = get_str_timestamp(dateplus)
28
29print(date)
30print(date_stamp)
31#canonicalizedHeaders = "x-obs-acl:private\n"
32canonicalizedHeaders = ""
33CanonicalizedResource = "/ctslogstorage/CloudTraces/la-south-2/2023/09/15/system/ECS/CloudTrace_la-south-2_2023-09-15T15-46-20Z_5bfdd257091735a3.json.gz"
34canonical_string = httpMethod + "\n" + contentmd5 + "\n" + contentType + "\n" + str(date_stamp) + "\n" + canonicalizedHeaders + CanonicalizedResource
35if IS_PYTHON2:
36     hashed = hmac.new(yourSecretAccessKeyID, canonical_string, hashlib.sha1)
37     encode_canonical = binascii.b2a_base64(hashed.digest())[:-1]
38else:
39     hashed = hmac.new(yourSecretAccessKeyID.encode('UTF-8'), canonical_string.encode('UTF-8'),hashlib.sha1)
40     encode_canonical = binascii.b2a_base64(hashed.digest())[:-1].decode('UTF-8')
41print(encode_canonical)
42
43url = 'https://ctslogstorage.obs.la-south-2.myhuaweicloud.com/CloudTraces/la-south-2/2023/09/15/system/ECS/CloudTrace_la-south-2_2023-09-15T15-46-20Z_5bfdd257091735a3.json.gz?AccessKeyId=yourakinformation&Expires=' + str(date_stamp) + '&Signature=' + urllib.request.quote(encode_canonical)
44
45print(url)

这里是参考了Header中携带签名里的代码进行修改的。注意和Header中携带签名不同的是,这里的时间和header中使用的在生成stringtosign时是不同的:

  • Header中携带签名使用的是 15 Sep 2023 09:20:09 GMT 这样的格式;
  • URL中携带时,使用的expires时间是1532779451这样的格式(相较1970-1-1后的秒数)。

signature
signature

这里还有一个坑一定要注意(这个也是在读取了JAVA样例代码后发现的),最后生成的这个signature一定要URL串化的,注意看图上的最后一个等号在调用时是不是变成了%3D,如果里面有加号,会变成%2B,这个地方如果不改是不会成功的,会报错 SignatureDoesNotMatch

signaturenotmatch
signaturenotmatch

由于我这里读取的是存到OBS里的CTS审计日志,其默认是以json.gz格式存在OBS里的,对应的读取方法如下:

 1import requests
 2
 3r = requests.get(url)
 4with open("file.json.gz", "wb") as f:
 5    f.write(r.content)
 6
 7import gzip
 8import json
 9
10# 读取json.gz文件
11with gzip.open('file.json.gz', 'r') as f:
12    content = f.read()
13
14json_data = json.loads(content)
15
16# 打印json数据
17print(json_data)

三、Java实现方式

由于JAVA代码官方有直接示例,就比较简单,按如下方式即可:

  1[root@ecs-acc1 ~]# cat SignDemo.java
  2import java.io.UnsupportedEncodingException;
  3import java.net.URLEncoder;
  4import java.security.InvalidKeyException;
  5import java.security.NoSuchAlgorithmException;
  6import java.util.ArrayList;
  7import java.util.Arrays;
  8import java.util.Base64;
  9import java.util.Collections;
 10import java.util.HashMap;
 11import java.util.List;
 12import java.util.Locale;
 13import java.util.Map;
 14import java.util.TreeMap;
 15import java.util.regex.Pattern;
 16
 17import javax.crypto.Mac;
 18import javax.crypto.spec.SecretKeySpec;
 19
 20public class SignDemo {
 21
 22    private static final String SIGN_SEP = "\n";
 23
 24    private static final String OBS_PREFIX = "x-obs-";
 25
 26    private static final String DEFAULT_ENCODING = "UTF-8";
 27
 28    private static final List<String> SUB_RESOURCES = Collections.unmodifiableList(Arrays.asList(
 29            "CDNNotifyConfiguration", "acl", "append", "attname", "backtosource", "cors", "customdomain", "delete",
 30            "deletebucket", "directcoldaccess", "encryption", "inventory", "length", "lifecycle", "location", "logging",
 31            "metadata", "mirrorBackToSource", "modify", "name", "notification", "obscompresspolicy",
 32            "partNumber", "policy", "position", "quota","rename", "replication", "response-cache-control",
 33            "response-content-disposition","response-content-encoding", "response-content-language", "response-content-type",
 34            "response-expires","restore", "storageClass", "storagePolicy", "storageinfo", "tagging", "torrent", "truncate",
 35            "uploadId", "uploads", "versionId", "versioning", "versions", "website", "x-image-process",
 36            "x-image-save-bucket", "x-image-save-object", "x-obs-security-token", "object-lock", "retention"));
 37
 38    private String ak;
 39
 40    private String sk;
 41
 42    private boolean isBucketNameValid(String bucketName) {
 43        if (bucketName == null || bucketName.length() > 63 || bucketName.length() < 3) {
 44            return false;
 45        }
 46
 47        if (!Pattern.matches("^[a-z0-9][a-z0-9.-]+$", bucketName)) {
 48            return false;
 49        }
 50
 51        if (Pattern.matches("(\\d{1,3}\\.){3}\\d{1,3}", bucketName)) {
 52            return false;
 53        }
 54
 55        String[] fragments = bucketName.split("\\.");
 56        for (int i = 0; i < fragments.length; i++) {
 57            if (Pattern.matches("^-.*", fragments[i]) || Pattern.matches(".*-$", fragments[i])
 58                    || Pattern.matches("^$", fragments[i])) {
 59                return false;
 60            }
 61        }
 62
 63        return true;
 64    }
 65
 66    public String encodeUrlString(String path) throws UnsupportedEncodingException {
 67        return URLEncoder.encode(path, DEFAULT_ENCODING)
 68                .replaceAll("\\+", "%20")
 69                .replaceAll("\\*", "%2A")
 70                .replaceAll("%7E", "~");
 71    }
 72
 73    public String encodeObjectName(String objectName) throws UnsupportedEncodingException {
 74        StringBuilder result = new StringBuilder();
 75        String[] tokens = objectName.split("/");
 76        for (int i = 0; i < tokens.length; i++) {
 77            result.append(this.encodeUrlString(tokens[i]));
 78            if (i < tokens.length - 1) {
 79                result.append("/");
 80            }
 81        }
 82        return result.toString();
 83    }
 84
 85    private String join(List<?> items, String delimiter) {
 86        StringBuilder sb = new StringBuilder();
 87        for (int i = 0; i < items.size(); i++) {
 88            String item = items.get(i).toString();
 89            sb.append(item);
 90            if (i < items.size() - 1) {
 91                sb.append(delimiter);
 92            }
 93        }
 94        return sb.toString();
 95    }
 96
 97    private boolean isValid(String input) {
 98        return input != null && !input.equals("");
 99    }
100
101    public String hmacSha1(String input) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException {
102        SecretKeySpec signingKey = new SecretKeySpec(this.sk.getBytes(DEFAULT_ENCODING), "HmacSHA1");
103        Mac mac = Mac.getInstance("HmacSHA1");
104        mac.init(signingKey);
105        return Base64.getEncoder().encodeToString(mac.doFinal(input.getBytes(DEFAULT_ENCODING)));
106    }
107
108    private String stringToSign(String httpMethod, Map<String, String[]> headers, Map<String, String> queries,
109                                String bucketName, String objectName) throws Exception {
110        String contentMd5 = "";
111        String contentType = "";
112        String date = "";
113
114        TreeMap<String, String> canonicalizedHeaders = new TreeMap<String, String>();
115
116        String key;
117        List<String> temp = new ArrayList<String>();
118        for (Map.Entry<String, String[]> entry : headers.entrySet()) {
119            key = entry.getKey();
120            if (key == null || entry.getValue() == null || entry.getValue().length == 0) {
121                continue;
122            }
123
124            key = key.trim().toLowerCase(Locale.ENGLISH);
125            if (key.equals("content-md5")) {
126                contentMd5 = entry.getValue()[0];
127                continue;
128            }
129
130            if (key.equals("content-type")) {
131                contentType = entry.getValue()[0];
132                continue;
133            }
134
135            if (key.equals("date")) {
136                date = entry.getValue()[0];
137                continue;
138            }
139
140            if (key.startsWith(OBS_PREFIX)) {
141
142                for (String value : entry.getValue()) {
143                    if (value != null) {
144                        temp.add(value.trim());
145                    }
146                }
147
148                canonicalizedHeaders.put(key, this.join(temp, ","));
149                temp.clear();
150            }
151        }
152
153        if (canonicalizedHeaders.containsKey("x-obs-date")) {
154            date = "";
155        }
156
157
158        // handle method/content-md5/content-type/date
159        StringBuilder stringToSign = new StringBuilder();
160        stringToSign.append(httpMethod).append(SIGN_SEP)
161                .append(contentMd5).append(SIGN_SEP)
162                .append(contentType).append(SIGN_SEP)
163                .append(date).append(SIGN_SEP);
164
165
166        // handle canonicalizedHeaders
167        for (Map.Entry<String, String> entry : canonicalizedHeaders.entrySet()) {
168            stringToSign.append(entry.getKey()).append(":").append(entry.getValue()).append(SIGN_SEP);
169        }
170
171
172        // handle CanonicalizedResource
173        stringToSign.append("/");
174        if (this.isValid(bucketName)) {
175            stringToSign.append(bucketName).append("/");
176            if (this.isValid(objectName)) {
177                stringToSign.append(this.encodeObjectName(objectName));
178            }
179        }
180
181        TreeMap<String, String> canonicalizedResource = new TreeMap<String, String>();
182        for (Map.Entry<String, String> entry : queries.entrySet()) {
183            key = entry.getKey();
184            if (key == null) {
185                continue;
186            }
187
188            if (SUB_RESOURCES.contains(key)) {
189                canonicalizedResource.put(key, entry.getValue());
190            }
191        }
192
193        if (canonicalizedResource.size() > 0) {
194            stringToSign.append("?");
195            for (Map.Entry<String, String> entry : canonicalizedResource.entrySet()) {
196                stringToSign.append(entry.getKey());
197                if (this.isValid(entry.getValue())) {
198                    stringToSign.append("=").append(entry.getValue());
199                }
200                stringToSign.append("&");
201            }
202            stringToSign.deleteCharAt(stringToSign.length() - 1);
203        }
204        //              System.out.println(String.format("StringToSign:%s%s", SIGN_SEP, stringToSign.toString()));
205
206        return stringToSign.toString();
207    }
208
209    public String querySignature(String httpMethod, Map<String, String[]> headers, Map<String, String> queries,
210                                 String bucketName, String objectName, long expires) throws Exception {
211        if (!isBucketNameValid(bucketName)) {
212            throw new IllegalArgumentException("the bucketName is illegal");
213        }
214        if (headers.containsKey("x-obs-date")) {
215            headers.put("x-obs-date", new String[]{String.valueOf(expires)});
216        } else {
217            headers.put("date", new String[]{String.valueOf(expires)});
218        }
219        //1. stringToSign
220        String stringToSign = this.stringToSign(httpMethod, headers, queries, bucketName, objectName);
221
222        //2. signature
223        return this.encodeUrlString(this.hmacSha1(stringToSign));
224    }
225
226    public String getURL(String endpoint, Map<String, String> queries,
227                         String bucketName, String objectName, String signature, long expires) throws UnsupportedEncodingException {
228        StringBuilder URL = new StringBuilder();
229        URL.append("https://").append(bucketName).append(".").append(endpoint).append("/").
230                append(this.encodeObjectName(objectName)).append("?");
231        String key;
232        for (Map.Entry<String, String> entry : queries.entrySet()) {
233            key = entry.getKey();
234            if (key == null) {
235                continue;
236            }
237            if (SUB_RESOURCES.contains(key)) {
238                String value = entry.getValue();
239                URL.append(key);
240                if (value != null) {
241                    URL.append("=").append(value).append("&");
242                } else {
243                    URL.append("&");
244                }
245            }
246        }
247        URL.append("AccessKeyId=").append(this.ak).append("&Expires=").append(expires).
248                append("&Signature=").append(signature);
249        return URL.toString();
250    }
251
252    public static void main(String[] args) throws Exception {
253        SignDemo demo = new SignDemo();
254        demo.ak = "myak";
255        demo.sk = "mysk";
256        String endpoint = "obs.la-south-2.myhuaweicloud.com";
257
258        String bucketName = "ctslogstorage";
259        String objectName = "CloudTraces/la-south-2/2023/09/15/system/ECS/CloudTrace_la-south-2_2023-09-15T15-46-20Z_5bfdd257091735a3.json.gz";
260
261        // 若直接使用URL在浏览器地址栏中访问,无法带上头域,此处headers加入头域会导致签名不匹配,使用headers需要客户端处理
262        Map<String, String[]> headers = new HashMap<String, String[]>();
263        Map<String, String> queries = new HashMap<String, String>();
264
265        // 请求消息参数Expires,设置24小时后失效
266        long expires = (System.currentTimeMillis() + 86400000L) / 1000;
267        String signature = demo.querySignature("GET", headers, queries, bucketName, objectName, expires);
268        System.out.println(signature);
269        String URL = demo.getURL(endpoint, queries, bucketName, objectName, signature, expires);
270        System.out.println(URL);
271    }
272}
273
274[root@ecs-acc1 ~]# java SignDemo.java
275qAC0mnBMMRIsSMoaHZbMhhQQE64%3D
276https://ctslogstorage.obs.la-south-2.myhuaweicloud.com/CloudTraces/la-south-2/2023/09/15/system/ECS/CloudTrace_la-south-2_2023-09-15T15-46-20Z_5bfdd257091735a3.json.gz?AccessKeyId=myak&Expires=1695401956&Signature=qAC0mnBMMRIsSMoaHZbMhhQQE64%3D

四、最后

除了API方式,官方还提供了SDK和其他工具调用的方式,由于我这里后面要结合 Functiongraph 进行调用,所以选择了 API来实现。如果是平时主机上调用,建议直接使用官方给的 obsutil browser工具操作,更简单。