View Javadoc
1   package com.github.davidmoten.aws.lw.client;
2   
3   import java.io.IOException;
4   import java.io.UnsupportedEncodingException;
5   import java.net.URL;
6   import java.net.URLDecoder;
7   import java.util.ArrayList;
8   import java.util.Collections;
9   import java.util.HashMap;
10  import java.util.List;
11  import java.util.Map;
12  import java.util.Optional;
13  import java.util.stream.Collectors;
14  
15  import com.github.davidmoten.aws.lw.client.internal.Clock;
16  import com.github.davidmoten.aws.lw.client.internal.auth.AwsSignatureVersion4;
17  import com.github.davidmoten.aws.lw.client.internal.util.Preconditions;
18  import com.github.davidmoten.aws.lw.client.internal.util.Util;
19  
20  final class RequestHelper {
21  
22      private RequestHelper() {
23          // prevent instantiation
24      }
25  
26      static void put(Map<String, List<String>> map, String name, String value) {
27          Preconditions.checkNotNull(map);
28          Preconditions.checkNotNull(name);
29          Preconditions.checkNotNull(value);
30          List<String> list = map.get(name);
31          if (list == null) {
32              list = new ArrayList<>();
33              map.put(name, list);
34          }
35          list.add(value);
36      }
37  
38      static Map<String, String> combineHeaders(Map<String, List<String>> headers) {
39          Preconditions.checkNotNull(headers);
40          return headers.entrySet().stream().collect(Collectors.toMap(x -> x.getKey(),
41                  x -> x.getValue().stream().collect(Collectors.joining(","))));
42      }
43  
44      static String presignedUrl(Clock clock, String url, String method, Map<String, String> headers,
45              byte[] requestBody, String serviceName, Optional<String> regionName, Credentials credentials,
46              int connectTimeoutMs, int readTimeoutMs, long expirySeconds, boolean signPayload) {
47  
48          // the region-specific endpoint to the target object expressed in path style
49          URL endpointUrl = Util.toUrl(url);
50  
51          Map<String, String> h = new HashMap<>(headers);
52          final String contentHashString;
53          if (isEmpty(requestBody)) {
54              contentHashString = AwsSignatureVersion4.UNSIGNED_PAYLOAD;
55              h.put("x-amz-content-sha256", "");
56          } else if (!signPayload) {
57              contentHashString = AwsSignatureVersion4.UNSIGNED_PAYLOAD;
58              h.put("x-amz-content-sha256", contentHashString);
59          } else {
60              // compute hash of the body content
61              byte[] contentHash = Util.sha256(requestBody);
62              contentHashString = Util.toHex(contentHash);
63              h.put("content-length", "" + requestBody.length);
64              h.put("x-amz-content-sha256", contentHashString);
65          }
66  
67          List<Parameter> parameters = extractQueryParameters(endpointUrl);
68          // don't use Collectors.toMap because it doesn't accept null values in map
69          Map<String, String> q = new HashMap<>();
70          parameters.forEach(p -> q.put(p.name, p.value));
71          
72          // construct the query parameter string to accompany the url
73  
74          // for SignatureV4, the max expiry for a presigned url is 7 days,
75          // expressed in seconds
76          q.put("X-Amz-Expires", "" + expirySeconds);
77  
78          String authorizationQueryParameters = AwsSignatureVersion4.computeSignatureForQueryAuth(
79                  endpointUrl, method, serviceName, regionName, clock, h, q, contentHashString,
80                  credentials.accessKey(), credentials.secretKey(), credentials.sessionToken());
81  
82          // build the presigned url to incorporate the authorization elements as query
83          // parameters
84          String u = endpointUrl.toString();
85          final String presignedUrl;
86          if (u.contains("?")) {
87              presignedUrl = u + "&" + authorizationQueryParameters;
88          } else {
89              presignedUrl = u + "?" + authorizationQueryParameters;
90          }
91          return presignedUrl;
92      }
93  
94      private static void includeTokenIfPresent(Credentials credentials, Map<String, String> h) {
95          if (credentials.sessionToken().isPresent()) {
96              h.put("x-amz-security-token", credentials.sessionToken().get());
97          }
98      }
99  
100     static ResponseInputStream request(Clock clock, HttpClient httpClient, String url,
101             HttpMethod method, Map<String, String> headers, byte[] requestBody, String serviceName,
102             Optional<String> regionName, Credentials credentials, int connectTimeoutMs, int readTimeoutMs, //
103             boolean signPayload) throws IOException {
104 
105         // the region-specific endpoint to the target object expressed in path style
106         URL endpointUrl = Util.toUrl(url);
107 
108         Map<String, String> h = new HashMap<>(headers);
109         final String contentHashString;
110         if (isEmpty(requestBody)) {
111             contentHashString = AwsSignatureVersion4.EMPTY_BODY_SHA256;
112         } else {
113             if (!signPayload) {
114                 contentHashString = AwsSignatureVersion4.UNSIGNED_PAYLOAD;
115             } else {
116                 // compute hash of the body content
117                 byte[] contentHash = Util.sha256(requestBody);
118                 contentHashString = Util.toHex(contentHash);
119             }
120             h.put("content-length", "" + requestBody.length);
121         }
122         h.put("x-amz-content-sha256", contentHashString);
123 
124         includeTokenIfPresent(credentials, h);
125 
126         List<Parameter> parameters = extractQueryParameters(endpointUrl);
127         // don't use Collectors.toMap because it doesn't accept null values in map
128         Map<String, String> q = new HashMap<>();
129         parameters.forEach(p -> q.put(p.name, p.value));
130         String authorization = AwsSignatureVersion4.computeSignatureForAuthorizationHeader(
131                 endpointUrl, method.toString(), serviceName, regionName.orElse("us-east-1"), clock, h, q,
132                 contentHashString, credentials.accessKey(), credentials.secretKey());
133 
134         // place the computed signature into a formatted 'Authorization' header
135         // and call S3
136         h.put("Authorization", authorization);
137         return httpClient.request(endpointUrl, method.toString(), h, requestBody, connectTimeoutMs,
138                 readTimeoutMs);
139     }
140 
141     private static List<Parameter> extractQueryParameters(URL endpointUrl) {
142         String query = endpointUrl.getQuery();
143         if (query == null) {
144             return Collections.emptyList();
145         } else {
146             return extractQueryParameters(query);
147         }
148     }
149 
150     private static final char QUERY_PARAMETER_SEPARATOR = '&';
151     private static final char QUERY_PARAMETER_VALUE_SEPARATOR = '=';
152 
153     /**
154      * Extract parameters from a query string, preserving encoding.
155      * <p>
156      * We can't use Apache HTTP Client's URLEncodedUtils.parse, mainly because we
157      * don't want to decode names/values.
158      *
159      * @param rawQuery the query to parse
160      * @return The list of parameters, in the order they were found.
161      */
162     // VisibleForTesting
163     static List<Parameter> extractQueryParameters(String rawQuery) {
164         List<Parameter> results = new ArrayList<>();
165         int endIndex = rawQuery.length() - 1;
166         int index = 0;
167         while (index <= endIndex) {
168             /*
169              * Ideally we should first look for '&', then look for '=' before the '&', but
170              * obviously that's not how AWS understand query parsing; see the test
171              * "post-vanilla-query-nonunreserved" in the test suite. A string such as
172              * "?foo&bar=qux" will be understood as one parameter with name "foo&bar" and
173              * value "qux". Don't ask me why.
174              */
175             String name;
176             String value;
177             int nameValueSeparatorIndex = rawQuery.indexOf(QUERY_PARAMETER_VALUE_SEPARATOR, index);
178             if (nameValueSeparatorIndex < 0) {
179                 // No value
180                 name = rawQuery.substring(index);
181                 value = null;
182 
183                 index = endIndex + 1;
184             } else {
185                 int parameterSeparatorIndex = rawQuery.indexOf(QUERY_PARAMETER_SEPARATOR,
186                         nameValueSeparatorIndex);
187                 if (parameterSeparatorIndex < 0) {
188                     parameterSeparatorIndex = endIndex + 1;
189                 }
190                 name = rawQuery.substring(index, nameValueSeparatorIndex);
191                 value = rawQuery.substring(nameValueSeparatorIndex + 1, parameterSeparatorIndex);
192 
193                 index = parameterSeparatorIndex + 1;
194             }
195             // note that value = null is valid as we can have a parameter without a value in
196             // a query string (legal http)
197             results.add(parameter(name, value, "UTF-8"));
198         }
199         return results;
200     }
201 
202     // VisibleForTesting
203     static Parameter parameter(String name, String value, String charset) {
204         try {
205             return new Parameter(URLDecoder.decode(name, charset),
206                     value == null ? value : URLDecoder.decode(value, charset));
207         } catch (UnsupportedEncodingException e) {
208             throw new RuntimeException(e);
209         }
210     }
211 
212     // VisibleForTesting
213     static final class Parameter {
214         final String name;
215         final String value;
216 
217         Parameter(String name, String value) {
218             this.name = name;
219             this.value = value;
220         }
221     }
222 
223     static boolean isEmpty(byte[] array) {
224         return array == null || array.length == 0;
225     }
226 
227 }